51 Commits

Author SHA1 Message Date
Nathaniel Landau
c1a40ed8a4 bump(release): v0.8.0 → v0.9.0 2023-03-20 18:20:10 -04:00
Nathaniel Landau
6f14076e33 fix: find more instances of inline metadata 2023-03-20 18:15:05 -04:00
Nathaniel Landau
ca42823a2f fix: ensure frontmatter values are unique within a key 2023-03-20 13:59:58 -04:00
Nathaniel Landau
36adfece51 fix: improve validation of bulk imports 2023-03-20 12:56:22 -04:00
Nathaniel Landau
d636fb2672 feat: bulk update metadata from a CSV file 2023-03-20 00:19:12 -04:00
Nathaniel Landau
593dbc3b55 build: add script to bump dependencies 2023-03-18 19:17:23 -04:00
Nathaniel Landau
009801a691 style: pass additional linters 2023-03-17 14:30:50 -04:00
Nathaniel Landau
2493db5f23 fix: improve logging to screen 2023-03-13 07:56:49 -04:00
Nathaniel Landau
a2d69d034d bump(release): v0.7.0 → v0.8.0 2023-03-12 14:11:00 -04:00
Nathaniel Landau
556acc0d46 docs: include move metadata in documentation 2023-03-12 14:08:44 -04:00
Nathaniel Landau
8cefca2639 feat: move inline metadata to specific location in note (#27) 2023-03-12 13:58:55 -04:00
Nathaniel Landau
82e1cba34a fix: add back option to transpose menus 2023-03-12 11:19:53 -04:00
Nathaniel Landau
7f431353e1 bump(release): v0.6.1 → v0.7.0 2023-03-11 16:59:27 -05:00
Nathaniel Landau
4e49445b08 docs: add new screencast 2023-03-11 16:58:13 -05:00
Nathaniel Landau
5f9c79a9c1 fix: exit after committing changes 2023-03-11 16:55:21 -05:00
Nathaniel Landau
34e7c07dd9 fix: fix typo and sort order of options 2023-03-11 16:46:29 -05:00
Nathaniel Landau
32a838c8e4 ci: fix ruff linting 2023-03-11 16:27:38 -05:00
Nathaniel Landau
000ac1a16c feat: transpose metadata between frontmatter and inline 2023-03-11 16:20:50 -05:00
Nathaniel Landau
1eb2d30d47 feat: select insert location for new inline metadata 2023-03-11 16:20:49 -05:00
Nathaniel Landau
b6a3d115fd build(deps): bump deps 2023-03-09 21:44:07 -05:00
Nathaniel Landau
03e6ad59c4 bump(release): v0.6.0 → v0.6.1 2023-03-03 21:11:25 -05:00
Nathaniel Landau
0b744f65ee refactor: use single console instance 2023-03-03 21:10:43 -05:00
Nathaniel Landau
bf869cfc15 fix: improve error handling when frontmatter malformed 2023-03-03 21:02:32 -05:00
Nathaniel Landau
bd4b94aefa build(deps): bump dependencies 2023-03-03 20:28:50 -05:00
dependabot[bot]
3932717c7e ci(deps): bump devcontainers/ci from 0.2 to 0.3 (#22)
Bumps [devcontainers/ci](https://github.com/devcontainers/ci) from 0.2 to 0.3.
- [Release notes](https://github.com/devcontainers/ci/releases)
- [Commits](https://github.com/devcontainers/ci/compare/v0.2...v0.3)

---
updated-dependencies:
- dependency-name: devcontainers/ci
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-26 13:41:56 -05:00
dependabot[bot]
755151e2ed ci(deps): bump step-security from 2.1.0 to 2.2.0 (#21)
ci(deps): bump step-security/harden-runner from 2.1.0 to 2.2.0

Bumps [step-security/harden-runner](https://github.com/step-security/harden-runner) from 2.1.0 to 2.2.0.
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](18bf8ad2ca...c8454efe5d)

---
updated-dependencies:
- dependency-name: step-security/harden-runner
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-26 13:41:40 -05:00
Nathaniel Landau
8f8174a902 build: update ruff and pass linting 2023-02-26 10:41:17 -05:00
Nathaniel Landau
3bbcf3a987 build(deps): bump dependencies 2023-02-23 10:20:14 -05:00
Nathaniel Landau
347dd4271f bump(release): v0.5.0 → v0.6.0 2023-02-06 17:38:25 -05:00
Nathaniel Landau
167997f527 fix(ui): add seperator to top of select lists 2023-02-06 17:36:36 -05:00
Nathaniel Landau
0143967db8 feat: transpose metadata (#18)
* feat: transpose between frontmatter and inline metadata

* ci: improve codecode patch thresholds

* test: remove ansi escape sequences from `capsys.errout`

* test: improve fixture for shared keys

* build(deps): update dependencies

* refactor: use deepcopy

* docs: add transpose metadata
2023-02-06 17:31:42 -05:00
Nathaniel Landau
446374b335 fix: allow adding inline tags with same key different values (#17) 2023-02-05 13:07:48 -05:00
Nathaniel Landau
401d830942 fix: remove unnecessary question when viewing diffs 2023-02-05 10:28:53 -05:00
Nathaniel Landau
7eb8ff5fa8 ci: run on push in main only 2023-02-05 00:11:16 -05:00
Nathaniel Landau
2cca54320c bump(release): v0.4.0 → v0.5.0 2023-02-05 00:00:29 -05:00
Nathaniel Landau
d94d9f2197 feat: add new tags (#16) 2023-02-04 23:34:31 -05:00
Nathaniel Landau
17985615b3 feat: add new inline metadata (#15)
* feat: add new inline metadata to notes

* fix: prepend note content after frontmatter

* refactor: cleanup search patterns

* feat(regex): find top of note

* test: add headers

* fix: insert to specified location

* test: improve test coverage

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

* feat: export metadata to a CSV

* fix: finalize colors for questions

* feat: inspect frontmatter, inline, and tags separately

* feat: export metadata to JSON

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

* ci(codecov): adjust patch target percentage down

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

* refactor(vault): refactor filtering notes

* fix(application): improve usage display

* fix(application): improve colors of questions

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

* build(deps): update identify
2023-02-01 15:00:57 -05:00
Nathaniel Landau
6909738218 docs(readme): add badges 2023-01-30 22:00:47 +00:00
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
59 changed files with 7901 additions and 3084 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,30 @@
},
// 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",
"GrapeCity.gc-excelviewer",
"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": {},
@@ -89,9 +87,9 @@
"remoteUser": "vscode",
"postCreateCommand": "bash ./.devcontainer/post-install.sh",
"mounts": [
"source=${localEnv:HOME}/.git_stop_words,target=/home/vscode/.git_stop_words,type=bind,consistency=cached",
"source=${localEnv:HOME}/.gitconfig.local,target=/home/vscode/.gitconfig.local,type=bind,consistency=cached",
"source=${localEnv:HOME}/tmp,target=/home/vscode/tmp,type=bind"
// "source=${localEnv:HOME}/.git_stop_words,target=/home/vscode/.git_stop_words,type=bind,consistency=cached",
// "source=${localEnv:HOME}/.gitconfig.local,target=/home/vscode/.gitconfig.local,type=bind,consistency=cached",
// "source=${localEnv:HOME}/tmp,target=/home/vscode/tmp,type=bind"
]
// Use 'forwardPorts' to make a list of ports inside the container available locally.

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,21 +32,29 @@ _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
echo ""
header "Installing shfmt"
if ! command -v shfmt &>/dev/null; then
_execute_ "curl -sS https://webi.sh/shfmt | sh"
_execute_ -pv "curl -sS https://webi.sh/shfmt | sh"
fi
REPOS=(
@@ -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

@@ -1,20 +1,25 @@
---
name: "Python Code Checker"
name: "Automated Tests"
on:
workflow_dispatch:
push:
paths:
- ".github/workflows/python-code-checker.yml"
- ".github/workflows/automated-tests.yml"
- ".github/actions/**"
- "src/**"
- "tests/**"
- "pyproject.toml"
- "poetry.lock"
branches:
- main
pull_request:
types: [opened, reopened]
types:
- opened
- reopened
- synchronize
paths:
- ".github/workflows/python-code-checker.yml"
- ".github/workflows/automated-tests.yml"
- ".github/actions/**"
- "src/**"
- "tests/**"
@@ -33,7 +38,7 @@ jobs:
matrix:
python-version: ["3.10", "3.11"]
steps:
- uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0
- uses: step-security/harden-runner@c8454efe5d0bdefd25384362fe217428ca277d57 # v2.2.0
with:
egress-policy: block
disable-sudo: true
@@ -62,15 +67,9 @@ jobs:
- name: Lint with Mypy
run: poetry run mypy src/
- name: lint with ruff
run: poetry run ruff --extend-ignore=I001,D301 src/
run: poetry run ruff --extend-ignore=I001,D301,D401 src/
- name: check pyproject.toml
run: poetry run poetry check
- name: lint with black
run: poetry run black --check src/
- name: run vulture
run: poetry run vulture src/
- name: run interrogate
run: poetry run interrogate -c pyproject.toml .
# ----------------------------------------------
# run test suite
@@ -80,6 +79,13 @@ jobs:
poetry run coverage run
poetry run coverage report
poetry run coverage xml
# ----------------------------------------------
# confirm package builds
# ----------------------------------------------
- name: Build package
run: poetry build
# ----------------------------------------------
# upload coverage stats
# ----------------------------------------------

View File

@@ -2,11 +2,14 @@
name: Commit Linter
on:
pull_request:
types: [opened, reopened]
push:
branches:
- main
pull_request:
types:
- opened
- reopened
- synchronize
permissions: # added using https://github.com/step-security/secure-workflows
contents: read
@@ -20,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0
uses: step-security/harden-runner@c8454efe5d0bdefd25384362fe217428ca277d57 # v2.2.0
with:
egress-policy: block
allowed-endpoints: >

View File

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

View File

@@ -3,12 +3,17 @@ name: "Dev Container Checker"
on:
workflow_dispatch:
pull_request:
types: [opened, reopened]
push:
paths:
- ".devcontainer/**"
- ".github/workflows/devcontainer-checker.yml"
push:
branches:
- main
pull_request:
types:
- opened
- reopened
- synchronize
paths:
- ".devcontainer/**"
- ".github/workflows/devcontainer-checker.yml"
@@ -22,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0
- uses: step-security/harden-runner@c8454efe5d0bdefd25384362fe217428ca277d57 # v2.2.0
with:
egress-policy: block
allowed-endpoints: >
@@ -53,7 +58,7 @@ jobs:
uses: actions/checkout@v3
- name: Build and run dev container task
uses: devcontainers/ci@v0.2
uses: devcontainers/ci@v0.3
with:
runCmd: |
poe lint

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0
uses: step-security/harden-runner@c8454efe5d0bdefd25384362fe217428ca277d57 # v2.2.0
with:
egress-policy: block
allowed-endpoints: >

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0
uses: step-security/harden-runner@c8454efe5d0bdefd25384362fe217428ca277d57 # v2.2.0
with:
egress-policy: block
allowed-endpoints: >

View File

@@ -18,7 +18,7 @@ jobs:
matrix:
python-version: ["3.11"]
steps:
- uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0
- uses: step-security/harden-runner@c8454efe5d0bdefd25384362fe217428ca277d57 # v2.2.0
with:
egress-policy: block
disable-sudo: true

View File

@@ -5,7 +5,7 @@ default_stages: [commit, manual]
fail_fast: true
repos:
- repo: "https://github.com/commitizen-tools/commitizen"
rev: v2.40.0
rev: v2.42.1
hooks:
- id: commitizen
- id: commitizen-branch
@@ -61,10 +61,11 @@ repos:
entry: yamllint --strict --config-file .yamllint.yml
- repo: "https://github.com/charliermarsh/ruff-pre-commit"
rev: "v0.0.230"
rev: "v0.0.257"
hooks:
- id: ruff
args: ["--extend-ignore", "I001,D301,D401,PLR2004"]
args: ["--extend-ignore", "I001,D301,D401"]
exclude: tests/
- repo: "https://github.com/jendrikseipp/vulture"
rev: "v2.7"

View File

@@ -1,22 +1,118 @@
## v0.9.0 (2023-03-20)
### Feat
- bulk update metadata from a CSV file
### Fix
- find more instances of inline metadata
- ensure frontmatter values are unique within a key
- improve validation of bulk imports
- improve logging to screen
## v0.8.0 (2023-03-12)
### Feat
- move inline metadata to specific location in note (#27)
### Fix
- add `back` option to transpose menus
## v0.7.0 (2023-03-11)
### Feat
- transpose metadata between frontmatter and inline
- select insert location for new inline metadata
### Fix
- exit after committing changes
- fix typo and sort order of options
## v0.6.1 (2023-03-03)
### Fix
- improve error handling when frontmatter malformed
### Refactor
- use single console instance
## v0.6.0 (2023-02-06)
### Feat
- transpose metadata (#18)
### Fix
- **ui**: add seperator to top of select lists
- allow adding inline tags with same key different values (#17)
- remove unnecessary question when viewing diffs
## v0.5.0 (2023-02-04)
### Feat
- add new tags (#16)
- add new inline metadata (#15)
- **configuration**: `insert_location` specifies where content is added within notes
### Fix
- find more emojis
## v0.4.0 (2023-02-02)
### Feat
- export metadata (#14)
- export metadata to CSV
- export metadata to JSON
- export CSV or JSON from command line
- limit scope of notes with one or more filters (#13)
### Fix
- do not count in-page links as tags
- improve terminal colors of questions
## v0.3.0 (2023-01-30)
### Feat
- **application**: add new metadata to frontmatter (#9)
### Fix
- **application**: improve ux (#10)
## v0.2.0 (2023-01-25)
### Feat
- **configuration**: support multiple vaults in the configuration file (#6)
- **configuration**: support multiple vaults in the configuration file (#6)
### Refactor
- **application**: refactor questions to separate class (#7)
- **application**: refactor questions to separate class (#7)
## v0.1.1 (2023-01-23)
### Fix
- **notes**: diff now prints values in the form `[value]`
- **application**: exit after committing changes
- **notes**: diff now prints values in the form `[value]`
- **application**: exit after committing changes
## v0.1.0 (2023-01-22)
### Feat
- initial application release
- initial application release

163
README.md
View File

@@ -1,39 +1,116 @@
[![Python Code Checker](https://github.com/natelandau/obsidian-metadata/actions/workflows/python-code-checker.yml/badge.svg)](https://github.com/natelandau/obsidian-metadata/actions/workflows/python-code-checker.yml) [![codecov](https://codecov.io/gh/natelandau/obsidian-metadata/branch/main/graph/badge.svg?token=3F2R43SSX4)](https://codecov.io/gh/natelandau/obsidian-metadata)
[![PyPI version](https://badge.fury.io/py/obsidian-metadata.svg)](https://badge.fury.io/py/obsidian-metadata) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/obsidian-metadata) [![Python Code Checker](https://github.com/natelandau/obsidian-metadata/actions/workflows/automated-tests.yml/badge.svg)](https://github.com/natelandau/obsidian-metadata/actions/workflows/automated-tests.yml) [![codecov](https://codecov.io/gh/natelandau/obsidian-metadata/branch/main/graph/badge.svg?token=3F2R43SSX4)](https://codecov.io/gh/natelandau/obsidian-metadata)
# obsidian-metadata
A script to make batch updates to metadata in an Obsidian vault. Provides the following capabilities:
- `in-text tag`: delete every occurrence
- `in-text tags`: Rename tag (`#tag1` -> `#tag2`)
- `frontmatter`: Delete a key matching a regex pattern and all associated values
- `frontmatter`: Rename a key
- `frontmatter`: Delete a value matching a regex pattern from a specified key
- `frontmatter`: Rename a value from a specified key
- `inline metadata`: Delete a key matching a regex pattern and all associated values
- `inline metadata`: Rename a key
- `inline metadata`: Delete a value matching a regex pattern from a specified key
- `inline metadata`: Rename a value from a specified key
- `vault`: Create a backup of the Obsidian vault
A script to make batch updates to metadata in an Obsidian vault. No changes are
made to the Vault until they are explicitly committed.
[![asciicast](https://asciinema.org/a/DQk0ufza1azwU3QFkE6XV33nm.svg)](https://asciinema.org/a/DQk0ufza1azwU3QFkE6XV33nm)
## 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.
Requires Python v3.10 or above.
```bash
pip install obsidian-metadata
```
## Important Disclaimer
**It is strongly recommended that you back up your vault prior to committing changes.** This script makes changes directly to the markdown files in your vault. Once the changes are committed, there is no ability to recreate the original information unless you have a backup. Follow the instructions in the script to create a backup of your vault if needed. The author of this script is not responsible for any data loss that may occur. Use at your own risk.
## Usage
The script provides a menu of available actions. Make as many changes as you require and review them as you go. No changes are made to the Vault until they are explicitly committed.
[![asciicast](https://asciinema.org/a/553464.svg)](https://asciinema.org/a/553464)
### CLI Commands
- `--config-file`: Specify a custom configuration file location
- `--dry-run`: Make no destructive changes
- `--export-csv`: Specify a path and create a CSV export of all metadata
- `--export-json`: Specify a path and create a JSON export of all metadata
- `--help`: Shows interactive help and exits
- `--log-file`: Specify a log file location
- `--log-to-file`: Will log to a file
- `--vault-path`: Specify a path to an Obsidian Vault
- `--verbose`: Set verbosity level (0=WARN, 1=INFO, 2=DEBUG, 3=TRACE)
- `--version`: Prints the version number and exits
### Running the script
Once installed, run `obsidian-metadata` in your terminal to enter an interactive menu of sub-commands.
**Vault Actions**
- Backup: Create a backup of the vault.
- Delete Backup: Delete a backup of the vault.
**Export Metadata**
- Export all metadata to a CSV organized by metadata type
- Export all metadata to a CSV organized by note path
- Export all metadata to a JSON file organized by metadata type
**Inspect Metadata**
- **View all metadata in the vault**
- View all **frontmatter**
- View all **inline metadata**
- View all **inline tags**
**Filter Notes in Scope**: Limit the scope of notes to be processed with one or more filters.
- **Path filter (regex)**: Limit scope based on the path or filename
- **Metadata filter**: Limit scope based on a key or key/value pair
- **Tag filter**: Limit scope based on an in-text tag
- **List and clear filters**: List all current filters and clear one or all
- **List notes in scope**: List notes that will be processed.
**Bulk Edit Metadata** from a CSV file (See the _making bulk edits_ section below)
**Add Metadata**: Add new metadata to your vault.
When adding a new key to inline metadata, the `insert location` value in the config file will specify where in the note it will be inserted.
- **Add new metadata to the frontmatter**
- **Add new inline metadata** - Set `insert_location` in the config to control where the new metadata is inserted. (Default: Bottom)
- **Add new inline tag** - Set `insert_location` in the config to control where the new tag is inserted. (Default: Bottom)
**Rename Metadata**: Rename either a key and all associated values, a specific value within a key. or an in-text tag.
- **Rename a key**
- **Rename a value**
- **Rename an inline tag**
**Delete Metadata**: Delete either a key and all associated values, or a specific value.
- **Delete a key and associated values**
- **Delete a value from a key**
- **Delete an inline tag**
**Move Inline Metadata**: Move inline metadata to a specified location with a note
- **Move to Top**: Move all inline metadata beneath the frontmatter
- **Move to After Title**: Move all inline metadata beneath the first markdown header
- **Move to Bottom**: Move all inline metadata to the bottom of the note
**Transpose Metadata**: Move metadata from inline to frontmatter or the reverse.
When transposing to inline metadata, the `insert location` value in the config file will specify where in the note it will be inserted.
- **Transpose all metadata** - Moves all frontmatter to inline metadata, or the reverse
- **Transpose key** - Transposes a specific key and all it's values
- **Transpose value**- Transpose a specific key:value pair
**Review Changes**: Prior to committing changes, review all changes that will be made.
- **View a diff of the changes** that will be made
**Commit Changes**: Write the changes to disk. This step is not undoable.
- **Commit changes to the vault**
### Configuration
`obsidian-metadata` requires a configuration file at `~/.obsidian_metadata.toml`. On first run, this file will be created. You can specify a new location for the configuration file with the `--config-file` option.
`obsidian-metadata` requires a configuration file at `~/.obsidian_metadata.toml`. On first run, this file will be created. You can specify a new location for the configuration file with the `--config-file` option.
To add additional vaults, copy the default section and add the appropriate information. The script will prompt you to select a vault if multiple exist in the configuration file
@@ -48,13 +125,49 @@ Below is an example with two vaults.
# Folders within the vault to ignore when indexing metadata
exclude_paths = [".git", ".obsidian"]
# Location to add metadata. One of:
# TOP: Directly after frontmatter.
# AFTER_TITLE: After the first header following frontmatter.
# BOTTOM: The bottom of the note
insert_location = "BOTTOM"
["Vault Two"]
path = "/path/to/second_vault"
exclude_paths = [".git", ".obsidian"]
exclude_paths = [".git", ".obsidian", "daily_notes"]
insert_location = "AFTER_TITLE"
```
To bypass the configuration file and specify a vault to use at runtime use the `--vault-path` option.
### Making bulk edits
Bulk edits are supported by importing a CSV file containing the following columns. Column headers must be lowercase.
1. `path` - Path to note relative to the vault root folder
2. `type` - Type of metadata. One of `frontmatter`, `inline_metadata`, or `tag`
3. `key` - The key to add (leave blank for a tag)
4. `value` - the value to add to the key
An example valid CSV file is
```csv
path,type,key,value
folder 1/note1.md,frontmatter,fruits,apple
folder 1/note1.md,frontmatter,fruits,banana
folder 1/note1.md,inline_metadata,cars,toyota
folder 1/note1.md,inline_metadata,cars,honda
folder 1/note1.md,tag,,tag1
folder 1/note1.md,tag,,tag2
```
How bulk imports work:
- Only notes which match the path in the CSV file are updated
- Effected notes will have ALL of their metadata changed to reflect the values in the CSV file
- Existing metadata in an effected note will be rewritten. This may result in it's location and/or formatting within the note being changed
- inline tags ignore any value added to the `key` column
You can export all your notes with their associated metadata in this format from the "Export Metadata" section of the script to be used as a template for your bulk changes.
# Contributing
@@ -62,7 +175,7 @@ To bypass the configuration file and specify a vault to use at runtime use the `
There are two ways to contribute to this project.
### 1. Containerized development (Recommended)
### 1. Containerized development
1. Clone this repository. `git clone https://github.com/natelandau/obsidian-metadata`
2. Open the repository in Visual Studio Code
@@ -87,3 +200,7 @@ There are two ways to contribute to this project.
- Run `poetry add {package}` from within the development environment to install a run time dependency and add it to `pyproject.toml` and `poetry.lock`.
- Run `poetry remove {package}` from within the development environment to uninstall a run time dependency and remove it from `pyproject.toml` and `poetry.lock`.
- Run `poetry update` from within the development environment to upgrade all dependencies to the latest versions allowed by `pyproject.toml`.
```
```

View File

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

1751
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,2 @@
[virtualenvs]
in-project = true
in-project = true

View File

@@ -11,7 +11,7 @@
name = "obsidian-metadata"
readme = "README.md"
repository = "https://github.com/natelandau/obsidian-metadata"
version = "0.2.0"
version = "0.9.0"
[tool.poetry.scripts] # https://python-poetry.org/docs/pyproject/#scripts
obsidian-metadata = "obsidian_metadata.cli:app"
@@ -20,89 +20,46 @@
loguru = "^0.6.0"
python = "^3.10"
questionary = "^1.10.0"
rich = "^13.2.0"
regex = "^2022.10.31"
rich = "^13.3.2"
ruamel-yaml = "^0.17.21"
shellingham = "^1.4.0"
shellingham = "^1.5.0.post1"
tomlkit = "^0.11.6"
typer = "^0.7.0"
commitizen = "^2.42.1"
[tool.poetry.group.test.dependencies]
pytest = "^7.2.0"
pytest = "^7.2.2"
pytest-clarity = "^1.0.1"
pytest-mock = "^3.10.0"
pytest-pretty-terminal = "^1.1.0"
pytest-xdist = "^3.1.0"
pytest-xdist = "^3.2.1"
[tool.poetry.group.dev.dependencies]
absolufy-imports = "^0.3.1"
black = "^22.12.0"
commitizen = "^2.39.1"
coverage = "^7.0.4"
black = "^23.1.0"
commitizen = "^2.42.1"
coverage = "^7.2.2"
interrogate = "^1.5.0"
mypy = "^0.991"
pdoc = "^12.3.1"
pep8-naming = "^0.13.3"
poethepoet = "^0.18.0"
pre-commit = "^2.21.0"
ruff = "^0.0.217"
typeguard = "^2.13.3"
types-python-dateutil = "^2.8.19.5"
types-pyyaml = "^6.0.12.2"
mypy = "^1.1.1"
pdoc = "^13.0.0"
poethepoet = "^0.18.1"
pre-commit = "^3.2.0"
ruff = "0.0.257"
typeguard = "^3.0.1"
types-python-dateutil = "^2.8.19.10"
vulture = "^2.7"
sh = "2.0.3"
[tool.ruff] # https://github.com/charliermarsh/ruff
fix = true
ignore = [
"B006",
"B008",
"D107",
"D203",
"D204",
"D213",
"D215",
"D400",
"D404",
"D406",
"D407",
"D408",
"D409",
"D413",
"E501",
"N805",
"PGH001",
"PGH003",
"UP007",
]
ignore-init-module-imports = true
[tool.black]
line-length = 100
select = [
"A",
"B",
"BLE",
"C4",
"C90",
"D",
"E",
"ERA",
"F",
"I",
"N",
"PGH",
"PLC",
"PLE",
"PLR",
"PLW",
"RET",
"RUF",
"SIM",
"TID",
"UP",
"W",
"YTT",
]
src = ["src", "tests"]
target-version = "py310"
unfixable = ["ERA001", "F401", "F401", "UP007"]
[tool.commitizen]
bump_message = "bump(release): v$current_version → v$new_version"
changelog_incremental = true
tag_format = "v$version"
update_changelog_on_bump = true
version = "0.9.0"
version_files = ["pyproject.toml:version", "src/obsidian_metadata/__version__.py:__version__"]
[tool.coverage.report] # https://coverage.readthedocs.io/en/latest/config.html#report
exclude_lines = [
@@ -135,19 +92,6 @@
[tool.coverage.xml]
output = "reports/coverage.xml"
[tool.black]
line-length = 100
[tool.commitizen]
bump_message = "bump(release): v$current_version → v$new_version"
tag_format = "v$version"
update_changelog_on_bump = true
version = "0.2.0"
version_files = [
"pyproject.toml:version",
"src/obsidian_metadata/__version__.py:__version__",
]
[tool.interrogate]
exclude = ["build", "docs", "tests"]
fail-under = 90
@@ -181,6 +125,108 @@
testpaths = ["src", "tests"]
xfail_strict = true
[tool.ruff] # https://github.com/charliermarsh/ruff
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".hg",
".mypy_cache",
".nox",
".pants.d",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"venv",
]
# Avoiding flagging (and removing) `V101` from any `# noqa`
# directives, despite Ruff's lack of support for `vulture`.
external = ["V101"]
fix = true
ignore = [
"B006",
"B008",
"D107",
"D203",
"D204",
"D213",
"D215",
"D404",
"D406",
"D407",
"D408",
"D409",
"D413",
"E501",
"N805",
"PGH001",
"PGH003",
"UP007",
]
ignore-init-module-imports = true
line-length = 100
per-file-ignores = { "cli.py" = [
"PLR0912",
"PLR0913",
], "tests/*.py" = [
"PLR0913",
"PLR2004",
"S101",
] }
select = [
"A", # flake8-builtins
"ARG", # flake8-unused-arguments
"B", # flake8-bugbear
"BLE", # flake8-blind-exception
"C40", # flake8-comprehensions
"C90", # McCabe
"D", # pydocstyle
"E", # pycodestyle Errors
"ERA", # flake8-eradicate
"EXE", # flake8-executable
"F", # pyflakes
"I", # iSort
"N", # Pep8-naming
"PGH", # pygrep-hooks
"PLC", # pylint Convention
"PLE", # pylint Error
"PLR", # pylint Refactor
"PLW", # pylint Warning
"PT", # flake8-pytest-style
"PTH", # flake8-use-pathlib
"Q", # flake8-quotes
"RET", # flake8-return
"RUF", # Ruff-specific rules
"S", # flake8-bandit
"SIM", # flake8-simplify
"TID", # flake8-tidy-imports
"UP", # pyupgrade
"W", # pycodestyle Warnings
"YTT", # flake8-2020
]
src = ["src", "tests"]
target-version = "py310"
unfixable = ["ERA001", "F401", "F841", "UP007"]
[tool.ruff.mccabe]
# Unlike Flake8, default to a complexity level of 10.
max-complexity = 10
[tool.ruff.pydocstyle]
convention = "google"
[tool.ruff.pylint]
max-args = 6
[tool.vulture] # https://pypi.org/project/vulture/
# exclude = ["file*.py", "dir/"]
# ignore_decorators = ["@app.route", "@require_*"]
@@ -206,7 +252,7 @@
help = "Lint this package"
[[tool.poe.tasks.lint.sequence]]
shell = "ruff --extend-ignore=I001,D301 src/ tests/"
shell = "ruff src/ --no-fix"
[[tool.poe.tasks.lint.sequence]]
shell = "black --check src/ tests/"

150
scripts/update_dependencies.py Executable file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python
"""Script to update the pyproject.toml file with the latest versions of the dependencies."""
from pathlib import Path
from textwrap import wrap
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover
import tomli as tomllib # type: ignore [no-redef]
import sh
from rich.console import Console
console = Console()
def dryrun(msg: str) -> None:
"""Print a message if the dry run flag is set.
Args:
msg: Message to print
"""
console.print(f"[cyan]DRYRUN | {msg}[/cyan]")
def success(msg: str) -> None:
"""Print a success message without using logging.
Args:
msg: Message to print
"""
console.print(f"[green]SUCCESS | {msg}[/green]")
def warning(msg: str) -> None:
"""Print a warning message without using logging.
Args:
msg: Message to print
"""
console.print(f"[yellow]WARNING | {msg}[/yellow]")
def error(msg: str) -> None:
"""Print an error message without using logging.
Args:
msg: Message to print
"""
console.print(f"[red]ERROR | {msg}[/red]")
def notice(msg: str) -> None:
"""Print a notice message without using logging.
Args:
msg: Message to print
"""
console.print(f"[bold]NOTICE | {msg}[/bold]")
def info(msg: str) -> None:
"""Print a notice message without using logging.
Args:
msg: Message to print
"""
console.print(f"INFO | {msg}")
def usage(msg: str, width: int = 80) -> None:
"""Print a usage message without using logging.
Args:
msg: Message to print
width (optional): Width of the message
"""
for _n, line in enumerate(wrap(msg, width=width)):
if _n == 0:
console.print(f"[dim]USAGE | {line}")
else:
console.print(f"[dim] | {line}")
def debug(msg: str) -> None:
"""Print a debug message without using logging.
Args:
msg: Message to print
"""
console.print(f"[blue]DEBUG | {msg}[/blue]")
def dim(msg: str) -> None:
"""Print a message in dimmed color.
Args:
msg: Message to print
"""
console.print(f"[dim]{msg}[/dim]")
# Load the pyproject.toml file
pyproject = Path(__file__).parents[1] / "pyproject.toml"
if not pyproject.exists():
console.print("pyproject.toml file not found")
raise SystemExit(1)
with pyproject.open("rb") as f:
try:
data = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
raise SystemExit(1) from e
# Get the latest versions of all dependencies
info("Getting latest versions of dependencies...")
packages: dict = {}
for line in sh.poetry("--no-ansi", "show", "--outdated").splitlines():
package, current, latest = line.split()[:3]
packages[package] = {"current_version": current, "new_version": latest}
if not packages:
success("All dependencies are up to date")
raise SystemExit(0)
dependencies = data["tool"]["poetry"]["dependencies"]
groups = data["tool"]["poetry"]["group"]
for p in dependencies:
if p in packages:
notice(
f"Updating {p} from {packages[p]['current_version']} to {packages[p]['new_version']}"
)
sh.poetry("add", f"{p}@latest", _fg=True)
for group in groups:
for p in groups[group]["dependencies"]:
if p in packages:
notice(
f"Updating {p} from {packages[p]['current_version']} to {packages[p]['new_version']}"
)
sh.poetry("add", f"{p}@{packages[p]['new_version']}", "--group", group, _fg=True)
sh.poetry("update", _fg=True)
success("All dependencies are up to date")
raise SystemExit(0)

View File

@@ -1,2 +1,2 @@
"""obsidian-metadata version."""
__version__ = "0.2.0"
__version__ = "0.9.0"

View File

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

View File

@@ -6,9 +6,12 @@ from obsidian_metadata._utils.utilities import (
clean_dictionary,
clear_screen,
dict_contains,
dict_keys_to_lower,
dict_values_to_lists_strings,
docstring_parameter,
merge_dictionaries,
remove_markdown_sections,
validate_csv_bulk_imports,
version_callback,
)
@@ -16,11 +19,13 @@ __all__ = [
"alerts",
"clean_dictionary",
"clear_screen",
"dict_values_to_lists_strings",
"dict_contains",
"dict_keys_to_lower",
"dict_values_to_lists_strings",
"docstring_parameter",
"LoggerManager",
"merge_dictionaries",
"remove_markdown_sections",
"vault_validation",
"validate_csv_bulk_imports",
"version_callback",
]

View File

@@ -1,11 +1,36 @@
"""Logging and alerts."""
import sys
from enum import Enum
from pathlib import Path
from textwrap import wrap
import rich.repr
import typer
from loguru import logger
from rich import print
from obsidian_metadata._utils.console import console
class LogLevel(Enum):
"""Enum for log levels."""
TRACE = 5
DEBUG = 10
INFO = 20
SUCCESS = 25
WARNING = 30
ERROR = 40
CRITICAL = 50
EXCEPTION = 60
class VerboseLevel(Enum):
"""Enum for verbose levels."""
WARN = 0
INFO = 1
DEBUG = 2
TRACE = 3
def dryrun(msg: str) -> None:
@@ -14,7 +39,7 @@ def dryrun(msg: str) -> None:
Args:
msg: Message to print
"""
print(f"[cyan]DRYRUN | {msg}[/cyan]")
console.print(f"[cyan]DRYRUN | {msg}[/cyan]")
def success(msg: str) -> None:
@@ -23,7 +48,7 @@ def success(msg: str) -> None:
Args:
msg: Message to print
"""
print(f"[green]SUCCESS | {msg}[/green]")
console.print(f"[green]SUCCESS | {msg}[/green]")
def warning(msg: str) -> None:
@@ -32,7 +57,7 @@ def warning(msg: str) -> None:
Args:
msg: Message to print
"""
print(f"[yellow]WARNING | {msg}[/yellow]")
console.print(f"[yellow]WARNING | {msg}[/yellow]")
def error(msg: str) -> None:
@@ -41,7 +66,7 @@ def error(msg: str) -> None:
Args:
msg: Message to print
"""
print(f"[red]ERROR | {msg}[/red]")
console.print(f"[red]ERROR | {msg}[/red]")
def notice(msg: str) -> None:
@@ -50,7 +75,7 @@ def notice(msg: str) -> None:
Args:
msg: Message to print
"""
print(f"[bold]NOTICE | {msg}[/bold]")
console.print(f"[bold]NOTICE | {msg}[/bold]")
def info(msg: str) -> None:
@@ -59,7 +84,33 @@ def info(msg: str) -> None:
Args:
msg: Message to print
"""
print(f"INFO | {msg}")
console.print(f"INFO | {msg}")
def usage(msg: str, width: int = None) -> None:
"""Print a usage message without using logging.
Args:
msg: Message to print
width (optional): Width of the message
"""
if width is None:
width = console.width - 15
for _n, line in enumerate(wrap(msg, width=width)):
if _n == 0:
console.print(f"[dim]USAGE | {line}")
else:
console.print(f"[dim] | {line}")
def debug(msg: str) -> None:
"""Print a debug message without using logging.
Args:
msg: Message to print
"""
console.print(f"[blue]DEBUG | {msg}[/blue]")
def dim(msg: str) -> None:
@@ -68,7 +119,7 @@ def dim(msg: str) -> None:
Args:
msg: Message to print
"""
print(f"[dim]{msg}[/dim]")
console.print(f"[dim]{msg}[/dim]")
def _log_formatter(record: dict) -> str:
@@ -78,9 +129,12 @@ def _log_formatter(record: dict) -> str:
or record["level"].name == "SUCCESS"
or record["level"].name == "WARNING"
):
return "<level>{level: <8}</level> | <level>{message}</level>\n{exception}"
return "<level><normal>{level: <8} | {message}</normal></level>\n{exception}"
return "<level>{level: <8}</level> | <level>{message}</level> <fg #c5c5c5>({name}:{function}:{line})</fg #c5c5c5>\n{exception}"
if record["level"].name == "TRACE" or record["level"].name == "DEBUG":
return "<level><normal>{level: <8} | {message}</normal></level> <fg #c5c5c5>({name}:{function}:{line})</fg #c5c5c5>\n{exception}"
return "<level>{level: <8} | {message}</level> <fg #c5c5c5>({name}:{function}:{line})</fg #c5c5c5>\n{exception}"
@rich.repr.auto
@@ -124,10 +178,10 @@ class LoggerManager:
self.log_level = log_level
if self.log_file == Path("/logs") and self.log_to_file: # pragma: no cover
print("No log file specified")
console.print("No log file specified")
raise typer.Exit(1)
if self.verbosity >= 3:
if self.verbosity >= VerboseLevel.TRACE.value:
logger.remove()
logger.add(
sys.stderr,
@@ -137,7 +191,7 @@ class LoggerManager:
diagnose=True,
)
self.log_level = 5
elif self.verbosity == 2:
elif self.verbosity == VerboseLevel.DEBUG.value:
logger.remove()
logger.add(
sys.stderr,
@@ -147,7 +201,7 @@ class LoggerManager:
diagnose=True,
)
self.log_level = 10
elif self.verbosity == 1:
elif self.verbosity == VerboseLevel.INFO.value:
logger.remove()
logger.add(
sys.stderr,
@@ -190,9 +244,9 @@ class LoggerManager:
Returns:
bool: True if the current log level is TRACE or lower, False otherwise.
"""
if self.log_level <= 5:
if self.log_level <= LogLevel.TRACE.value:
if msg:
print(msg)
console.print(msg)
return True
return False
@@ -205,9 +259,9 @@ class LoggerManager:
Returns:
bool: True if the current log level is DEBUG or lower, False otherwise.
"""
if self.log_level <= 10:
if self.log_level <= LogLevel.DEBUG.value:
if msg:
print(msg)
console.print(msg)
return True
return False
@@ -220,9 +274,9 @@ class LoggerManager:
Returns:
bool: True if the current log level is INFO or lower, False otherwise.
"""
if self.log_level <= 20:
if self.log_level <= LogLevel.INFO.value:
if msg:
print(msg)
console.print(msg)
return True
return False
@@ -235,8 +289,8 @@ class LoggerManager:
Returns:
bool: True if the current log level is default or lower, False otherwise.
"""
if self.log_level <= 30:
if self.log_level <= LogLevel.WARNING.value:
if msg:
print(msg)
console.print(msg)
return True
return False # pragma: no cover

View File

@@ -0,0 +1,4 @@
"""Rich console object for the application."""
from rich.console import Console
console = Console()

View File

@@ -1,106 +1,16 @@
"""Utility functions."""
import csv
import re
from os import name, system
from pathlib import Path
from typing import Any
import typer
from obsidian_metadata.__version__ import __version__
def dict_values_to_lists_strings(dictionary: dict, strip_null_values: bool = False) -> dict:
"""Converts all values in a dictionary to lists of strings.
Args:
dictionary (dict): Dictionary to convert
strip_null (bool): Whether to strip null values
Returns:
dict: Dictionary with all values converted to lists of strings
{key: sorted(new_dict[key]) for key in sorted(new_dict)}
"""
new_dict = {}
if strip_null_values:
for key, value in dictionary.items():
if isinstance(value, list):
new_dict[key] = sorted([str(item) for item in value if item is not None])
elif isinstance(value, dict):
new_dict[key] = dict_values_to_lists_strings(value) # type: ignore[assignment]
elif value is None or value == "None" or value == "":
new_dict[key] = []
else:
new_dict[key] = [str(value)]
return new_dict
for key, value in dictionary.items():
if isinstance(value, list):
new_dict[key] = sorted([str(item) for item in value])
elif isinstance(value, dict):
new_dict[key] = dict_values_to_lists_strings(value) # type: ignore[assignment]
else:
new_dict[key] = [str(value)]
return new_dict
def remove_markdown_sections(
text: str,
strip_codeblocks: bool = False,
strip_inlinecode: bool = False,
strip_frontmatter: bool = False,
) -> str:
"""Strips markdown sections from text.
Args:
text (str): Text to remove code blocks from
strip_codeblocks (bool, optional): Strip code blocks. Defaults to False.
strip_inlinecode (bool, optional): Strip inline code. Defaults to False.
strip_frontmatter (bool, optional): Strip frontmatter. Defaults to False.
Returns:
str: Text without code blocks
"""
if strip_codeblocks:
text = re.sub(r"`{3}.*?`{3}", "", text, flags=re.DOTALL)
if strip_inlinecode:
text = re.sub(r"`.*?`", "", text)
if strip_frontmatter:
text = re.sub(r"^\s*---.*?---", "", text, flags=re.DOTALL)
return text # noqa: RET504
def version_callback(value: bool) -> None:
"""Print version and exit."""
if value:
print(f"{__package__.split('.')[0]}: v{__version__}")
raise typer.Exit()
def docstring_parameter(*sub: Any) -> Any:
"""Decorator to replace variables within docstrings.
Args:
sub (Any): Replacement variables
Usage:
@docstring_parameter("foo", "bar")
def foo():
'''This is a {0} docstring with {1} variables.'''
"""
def dec(obj: Any) -> Any:
"""Format object."""
obj.__doc__ = obj.__doc__.format(*sub)
return obj
return dec
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata._utils.console import console
def clean_dictionary(dictionary: dict[str, Any]) -> dict[str, Any]:
@@ -121,7 +31,7 @@ def clean_dictionary(dictionary: dict[str, Any]) -> dict[str, Any]:
def clear_screen() -> None: # pragma: no cover
"""Clears the screen."""
"""Clear the screen."""
# for windows
_ = system("cls") if name == "nt" else system("clear")
@@ -129,7 +39,7 @@ def clear_screen() -> None: # pragma: no cover
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 or if a specified key contains a value.
Args:
dictionary (dict): Dictionary to check
@@ -155,3 +65,190 @@ def dict_contains(
return any(found_keys)
return key in dictionary and value in dictionary[key]
def dict_keys_to_lower(dictionary: dict) -> dict:
"""Convert all keys in a dictionary to lowercase.
Args:
dictionary (dict): Dictionary to convert
Returns:
dict: Dictionary with all keys converted to lowercase
"""
return {key.lower(): value for key, value in dictionary.items()}
def dict_values_to_lists_strings(
dictionary: dict,
strip_null_values: bool = False,
) -> dict:
"""Convert all values in a dictionary to lists of strings.
Args:
dictionary (dict): Dictionary to convert
strip_null_values (bool): Whether to strip null values
Returns:
dict: Dictionary with all values converted to lists of strings
{key: sorted(new_dict[key]) for key in sorted(new_dict)}
"""
new_dict = {}
if strip_null_values:
for key, value in dictionary.items():
if isinstance(value, list):
new_dict[key] = sorted([str(item) for item in value if item is not None])
elif isinstance(value, dict):
new_dict[key] = dict_values_to_lists_strings(value) # type: ignore[assignment]
elif value is None or value == "None" or not value:
new_dict[key] = []
else:
new_dict[key] = [str(value)]
return new_dict
for key, value in dictionary.items():
if isinstance(value, list):
new_dict[key] = sorted([str(item) for item in value])
elif isinstance(value, dict):
new_dict[key] = dict_values_to_lists_strings(value) # type: ignore[assignment]
else:
new_dict[key] = [str(value)]
return new_dict
def docstring_parameter(*sub: Any) -> Any:
"""Replace variables within docstrings.
Args:
sub (Any): Replacement variables
Usage:
@docstring_parameter("foo", "bar")
def foo():
'''This is a {0} docstring with {1} variables.'''
"""
def dec(obj: Any) -> Any:
"""Format object."""
obj.__doc__ = obj.__doc__.format(*sub)
return obj
return dec
def merge_dictionaries(dict1: dict, dict2: dict) -> dict:
"""Merge two dictionaries. When the values are lists, they are merged and sorted.
Args:
dict1 (dict): First dictionary.
dict2 (dict): Second dictionary.
Returns:
dict: Merged dictionary.
"""
for k, v in dict2.items():
if k in dict1:
if isinstance(v, list):
dict1[k].extend(v)
else:
dict1[k] = v
for k, v in dict1.items():
if isinstance(v, list):
dict1[k] = sorted(set(v))
elif isinstance(v, dict): # pragma: no cover
for kk, vv in v.items():
if isinstance(vv, list):
v[kk] = sorted(set(vv))
return dict(sorted(dict1.items()))
def remove_markdown_sections(
text: str,
strip_codeblocks: bool = False,
strip_inlinecode: bool = False,
strip_frontmatter: bool = False,
) -> str:
"""Strip markdown sections from text.
Args:
text (str): Text to remove code blocks from
strip_codeblocks (bool, optional): Strip code blocks. Defaults to False.
strip_inlinecode (bool, optional): Strip inline code. Defaults to False.
strip_frontmatter (bool, optional): Strip frontmatter. Defaults to False.
Returns:
str: Text without code blocks
"""
if strip_codeblocks:
text = re.sub(r"`{3}.*?`{3}", "", text, flags=re.DOTALL)
if strip_inlinecode:
text = re.sub(r"`.*?`", "", text)
if strip_frontmatter:
text = re.sub(r"^\s*---.*?---", "", text, flags=re.DOTALL)
return text
def validate_csv_bulk_imports(csv_path: Path, note_paths: list) -> dict[str, list[dict[str, str]]]:
"""Validate the bulk import CSV file.
Args:
csv_path (dict): Dictionary to validate
note_paths (list): List of paths to all notes in vault
Returns:
dict: Validated dictionary
"""
csv_dict: dict[str, Any] = {}
with csv_path.expanduser().open("r") as csv_file:
csv_reader = csv.DictReader(csv_file, delimiter=",")
row_num = 0
for row in csv_reader:
if row_num == 0:
if "path" not in row:
raise typer.BadParameter("Missing 'path' column in CSV file")
if "type" not in row:
raise typer.BadParameter("Missing 'type' column in CSV file")
if "key" not in row:
raise typer.BadParameter("Missing 'key' column in CSV file")
if "value" not in row:
raise typer.BadParameter("Missing 'value' column in CSV file")
row_num += 1
if row["path"] not in csv_dict:
csv_dict[row["path"]] = []
csv_dict[row["path"]].append(
{"type": row["type"], "key": row["key"], "value": row["value"]}
)
if row_num == 0 or row_num == 1:
raise typer.BadParameter("Empty CSV file")
paths_to_remove = [x for x in csv_dict if x not in note_paths]
for _path in paths_to_remove:
alerts.warning(f"'{_path}' does not exist in vault. Skipping...")
del csv_dict[_path]
if len(csv_dict) == 0:
log.error("No paths in the CSV file matched paths in the vault")
raise typer.Exit(1)
return csv_dict
def version_callback(value: bool) -> None:
"""Print version and exit."""
if value:
console.print(f"{__package__.split('.')[0]}: v{__version__}")
raise typer.Exit()

View File

@@ -1,12 +1,10 @@
"""obsidian-metadata CLI."""
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 (
@@ -15,6 +13,7 @@ from obsidian_metadata._utils import (
docstring_parameter,
version_callback,
)
from obsidian_metadata._utils.console import console
from obsidian_metadata.models import Application
app = typer.Typer(add_completion=False, no_args_is_help=True, rich_markup_mode="rich")
@@ -28,16 +27,30 @@ HELP_TEXT = """
@app.command()
@docstring_parameter(__package__)
def main(
vault_path: Path = typer.Option(
None,
help="Path to Obsidian vault",
show_default=False,
),
config_file: Path = typer.Option(
Path(Path.home() / f".{__package__}.toml"),
help="Specify a custom path to a configuration file",
show_default=False,
),
export_csv: Path = typer.Option(
None,
help="Exports all metadata to a specified CSV file and exits. (Will overwrite any existing file)",
show_default=False,
dir_okay=False,
file_okay=True,
),
export_json: Path = typer.Option(
None,
help="Exports all metadata to a specified JSON file and exits. (Will overwrite any existing file)",
show_default=False,
dir_okay=False,
file_okay=True,
),
vault_path: Path = typer.Option(
None,
help="Path to Obsidian vault",
show_default=False,
),
dry_run: bool = typer.Option(
False,
"--dry-run",
@@ -66,33 +79,20 @@ def main(
help="""Set verbosity level (0=WARN, 1=INFO, 2=DEBUG, 3=TRACE)""",
count=True,
),
version: Optional[bool] = typer.Option(
version: Optional[bool] = typer.Option( # noqa: ARG001
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"""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:[/]
- [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]Usage:[/]
[tan]Obsidian-metadata[/] allows you to make batch updates to metadata in an Obsidian vault. Once you have made your changes, review them prior to committing them to the vault. The script provides a menu of available actions. Make as many changes as you require and review them as you go. No changes are made to the Vault until they are explicitly committed.
[bold underline]It is strongly recommended that you back up your vault prior to committing changes.[/] This script makes changes directly to the markdown files in your vault. Once the changes are committed, there is no ability to recreate the original information unless you have a backup. Follow the instructions in the script to create a backup of your vault if needed. The author of this script is not responsible for any data loss that may occur. Use at your own risk.
[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.
Full usage information is available at https://github.com/natelandau/obsidian-metadata
"""
# Instantiate logger
alerts.LoggerManager( # pragma: no cover
@@ -113,7 +113,7 @@ def main(
|_| |_|\___|\__\__,_|\__,_|\__,_|\__\__,_|
"""
clear_screen()
print(banner)
console.print(banner)
config: Config = Config(config_path=config_file, vault_path=vault_path)
if len(config.vaults) == 0:
@@ -134,7 +134,16 @@ def main(
vault_to_use = next(vault for vault in config.vaults if vault.name == vault_name)
application = Application(dry_run=dry_run, config=vault_to_use)
application.main_app()
if export_json is not None:
path = Path(export_json).expanduser().resolve()
application.noninteractive_export_json(path)
raise typer.Exit(code=0)
if export_csv is not None:
path = Path(export_json).expanduser().resolve()
application.noninteractive_export_csv(path)
raise typer.Exit(code=0)
application.application_main()
if __name__ == "__main__":

View File

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

View File

@@ -1,19 +1,21 @@
"""Questions for the cli."""
from pathlib import Path
from typing import Any
import questionary
from rich import print
import typer
from rich import box
from rich.table import Table
from obsidian_metadata._config import VaultConfig
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata.models import Patterns, Vault
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils import alerts, validate_csv_bulk_imports
from obsidian_metadata._utils.console import console
from obsidian_metadata.models import InsertLocation, Vault, VaultFilter
from obsidian_metadata.models.enums import MetadataType
from obsidian_metadata.models.questions import Questions
PATTERNS = Patterns()
class Application:
"""Questions for use in the cli.
@@ -27,117 +29,466 @@ class Application:
self.config = config
self.dry_run = dry_run
self.questions = Questions()
self.filters: list[VaultFilter] = []
def load_vault(self, path_filter: str = None) -> None:
"""Load the vault.
def _load_vault(self) -> None:
"""Load the vault."""
if len(self.filters) == 0:
self.vault: Vault = Vault(config=self.config, dry_run=self.dry_run)
else:
self.vault = Vault(config=self.config, dry_run=self.dry_run, filters=self.filters)
Args:
path_filter (str, optional): Regex to filter notes by path.
"""
self.vault: Vault = Vault(config=self.config, dry_run=self.dry_run, path_filter=path_filter)
log.info(f"Indexed {self.vault.num_notes()} notes from {self.vault.vault_path}")
alerts.success(
f"Loaded {len(self.vault.notes_in_scope)} notes from {len(self.vault.all_notes)} total notes"
)
self.questions = Questions(vault=self.vault)
def main_app(self) -> None:
def application_main(self) -> None:
"""Questions for the main application."""
self.load_vault()
self._load_vault()
while True:
print("\n")
self.vault.info()
match self.questions.ask_main_application(): # noqa: E999
case None:
break
match self.questions.ask_application_main():
case "vault_actions":
self.application_vault()
case "export_metadata":
self.application_export_metadata()
case "inspect_metadata":
self.application_inspect_metadata()
case "import_from_csv":
self.application_import_csv()
case "filter_notes":
self.load_vault(path_filter=self.questions.ask_for_filter_path())
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 "reorganize_metadata":
self.application_reorganize_metadata()
case "review_changes":
self.review_changes()
case "commit_changes":
self.commit_changes()
case _:
break
console.print("Done!")
def application_add_metadata(self) -> None:
"""Add metadata."""
alerts.usage(
"Add new metadata to your vault. Currently only supports adding to the frontmatter of a note."
)
area = self.questions.ask_area()
match area:
case MetadataType.FRONTMATTER | MetadataType.INLINE:
key = self.questions.ask_new_key(question="Enter the key for the new metadata")
if key is None: # pragma: no cover
return
value = self.questions.ask_new_value(
question="Enter the value for the new metadata"
)
if value is None: # pragma: no cover
return
num_changed = self.vault.add_metadata(
area=area, key=key, value=value, location=self.vault.insert_location
)
if num_changed == 0: # pragma: no cover
alerts.warning("No notes were changed")
return
alerts.success(f"Added metadata to {num_changed} notes")
case MetadataType.TAGS:
tag = self.questions.ask_new_tag()
if tag is None: # pragma: no cover
return
num_changed = self.vault.add_metadata(
area=area, value=tag, location=self.vault.insert_location
)
if num_changed == 0: # pragma: no cover
alerts.warning("No notes were changed")
return
alerts.success(f"Added metadata to {num_changed} notes")
case _: # pragma: no cover
return
def application_delete_metadata(self) -> None:
"""Delete metadata."""
alerts.usage("Delete either a key and all associated values, or a specific value.")
choices = [
questionary.Separator(),
{"name": "Delete inline tag", "value": "delete_inline_tag"},
{"name": "Delete key", "value": "delete_key"},
{"name": "Delete value", "value": "delete_value"},
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 _: # pragma: no cover
return
def application_rename_metadata(self) -> None:
"""Rename metadata."""
alerts.usage("Select the type of metadata to rename.")
choices = [
questionary.Separator(),
{"name": "Rename inline tag", "value": "rename_inline_tag"},
{"name": "Rename key", "value": "rename_key"},
{"name": "Rename value", "value": "rename_value"},
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 _: # pragma: no cover
return
def application_filter(self) -> None: # noqa: C901,PLR0911,PLR0912
"""Filter notes."""
alerts.usage("Limit the scope of notes to be processed with one or more filters.")
choices = [
questionary.Separator(),
{"name": "Apply new regex path filter", "value": "apply_path_filter"},
{"name": "Apply new metadata filter", "value": "apply_metadata_filter"},
{"name": "Apply new in-text tag filter", "value": "apply_tag_filter"},
{"name": "List and clear filters", "value": "list_filters"},
{"name": "List notes in scope", "value": "list_notes"},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
while True:
match self.questions.ask_selection(choices=choices, question="Select an action"):
case "apply_path_filter":
path = self.questions.ask_filter_path()
if path is None or not path: # pragma: no cover
return
self.filters.append(VaultFilter(path_filter=path))
self._load_vault()
case "apply_metadata_filter":
key = self.questions.ask_existing_key()
if key is None: # pragma: no cover
return
questions2 = Questions(vault=self.vault, key=key)
value = questions2.ask_existing_value(
question="Enter the value for the metadata filter",
)
if value is None: # pragma: no cover
return
if not value:
self.filters.append(VaultFilter(key_filter=key))
else:
self.filters.append(VaultFilter(key_filter=key, value_filter=value))
self._load_vault()
case "apply_tag_filter":
tag = self.questions.ask_existing_inline_tag()
if tag is None or not tag:
return
self.filters.append(VaultFilter(tag_filter=tag))
self._load_vault()
case "list_filters":
if len(self.filters) == 0:
alerts.notice("No filters have been applied")
return
console.print("")
table = Table(
"Opt",
"Filter",
"Type",
title="Current Filters",
show_header=False,
box=box.HORIZONTALS,
)
for _n, _filter in enumerate(self.filters, start=1):
if _filter.path_filter is not None:
table.add_row(
str(_n),
f"Path regex: [tan bold]{_filter.path_filter}",
end_section=bool(_n == len(self.filters)),
)
elif _filter.tag_filter is not None:
table.add_row(
str(_n),
f"Tag filter: [tan bold]{_filter.tag_filter}",
end_section=bool(_n == len(self.filters)),
)
elif _filter.key_filter is not None and _filter.value_filter is None:
table.add_row(
str(_n),
f"Key filter: [tan bold]{_filter.key_filter}",
end_section=bool(_n == len(self.filters)),
)
elif _filter.key_filter is not None and _filter.value_filter is not None:
table.add_row(
str(_n),
f"Key/Value : [tan bold]{_filter.key_filter}={_filter.value_filter}",
end_section=bool(_n == len(self.filters)),
)
table.add_row(f"{len(self.filters) + 1}", "Clear All")
table.add_row(f"{len(self.filters) + 2}", "Return to Main Menu")
console.print(table)
num = self.questions.ask_number(
question="Enter the number of the filter to clear"
)
if num is None:
return
if int(num) <= len(self.filters):
self.filters.pop(int(num) - 1)
self._load_vault()
return
if int(num) == len(self.filters) + 1:
self.filters = []
self._load_vault()
return
case "list_notes":
self.vault.list_editable_notes()
case _:
return
def application_import_csv(self) -> None:
"""Import CSV for bulk changes to metadata."""
alerts.usage(
"Import CSV to make build changes to metadata. The CSV must have the following columns: path, type, key, value. Where type is one of 'frontmatter', 'inline_metadata', or 'tag'. Note: this will not create new notes."
)
path = self.questions.ask_path(question="Enter path to a CSV file", valid_file=True)
if path is None:
return
csv_path = Path(path).expanduser()
if "csv" not in csv_path.suffix.lower():
alerts.error("File must be a CSV file")
return
note_paths = [
str(n.note_path.relative_to(self.vault.vault_path)) for n in self.vault.all_notes
]
dict_from_csv = validate_csv_bulk_imports(csv_path, note_paths)
num_changed = self.vault.update_from_dict(dict_from_csv)
if num_changed == 0:
alerts.warning("No notes were changed")
return
alerts.success(f"Rewrote metadata for {num_changed} notes.")
def application_export_metadata(self) -> None:
"""Export metadata to various formats."""
alerts.usage(
"Export the metadata in your vault. Note, uncommitted changes will be reflected in these files. The notes csv export can be used as template for importing bulk changes"
)
choices = [
questionary.Separator(),
{"name": "Metadata by type to CSV", "value": "export_csv"},
{"name": "Metadata by type to JSON", "value": "export_json"},
{
"name": "Metadata by note to CSV [Bulk import template]",
"value": "export_notes_csv",
},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
while True:
match self.questions.ask_selection(choices=choices, question="Export format"):
case "export_csv":
path = self.questions.ask_path(question="Enter a path for the CSV file")
if path is None:
return
self.vault.export_metadata(path=path, export_format="csv")
alerts.success(f"CSV written to {path}")
case "export_json":
path = self.questions.ask_path(question="Enter a path for the JSON file")
if path is None:
return
self.vault.export_metadata(path=path, export_format="json")
alerts.success(f"JSON written to {path}")
case "export_notes_csv":
path = self.questions.ask_path(question="Enter a path for the CSV file")
if path is None:
return
self.vault.export_notes_to_csv(path=path)
alerts.success(f"CSV written to {path}")
return
case _:
return
def application_inspect_metadata(self) -> None:
"""View metadata."""
alerts.usage(
"Inspect the metadata in your vault. Note, uncommitted changes will be reflected in these reports"
)
choices = [
questionary.Separator(),
{"name": "View all frontmatter", "value": "all_frontmatter"},
{"name": "View all inline metadata", "value": "all_inline"},
{"name": "View all inline tags", "value": "all_tags"},
{"name": "View all keys", "value": "all_keys"},
{"name": "View all metadata", "value": "all_metadata"},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
while True:
match self.questions.ask_selection(choices=choices, question="Select an action"):
case "all_metadata":
self.vault.metadata.print_metadata()
console.print("")
self.vault.metadata.print_metadata(area=MetadataType.ALL)
console.print("")
case "all_frontmatter":
console.print("")
self.vault.metadata.print_metadata(area=MetadataType.FRONTMATTER)
console.print("")
case "all_inline":
console.print("")
self.vault.metadata.print_metadata(area=MetadataType.INLINE)
console.print("")
case "all_keys":
console.print("")
self.vault.metadata.print_metadata(area=MetadataType.KEYS)
console.print("")
case "all_tags":
console.print("")
self.vault.metadata.print_metadata(area=MetadataType.TAGS)
console.print("")
case _:
return
def application_reorganize_metadata(self) -> None:
"""Reorganize metadata.
This portion of the application deals with moving metadata between types (inline to frontmatter, etc.) and moving the location of inline metadata within a note.
"""
alerts.usage("Move metadata within notes.")
alerts.usage(" 1. Transpose frontmatter to inline or vice versa.")
alerts.usage(" 2. Move the location of inline metadata within a note.")
choices = [
questionary.Separator(),
{"name": "Move inline metadata to top of note", "value": "move_to_top"},
{
"name": "Move inline metadata beneath the first header",
"value": "move_to_after_header",
},
{"name": "Move inline metadata to bottom of the note", "value": "move_to_bottom"},
{"name": "Transpose frontmatter to inline", "value": "frontmatter_to_inline"},
{"name": "Transpose inline to frontmatter", "value": "inline_to_frontmatter"},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
match self.questions.ask_selection(
choices=choices, question="Select metadata to transpose"
):
case "frontmatter_to_inline":
self.transpose_metadata(begin=MetadataType.FRONTMATTER, end=MetadataType.INLINE)
case "inline_to_frontmatter":
self.transpose_metadata(begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER)
case "move_to_top":
self.move_inline_metadata(location=InsertLocation.TOP)
case "move_to_after_header":
self.move_inline_metadata(location=InsertLocation.AFTER_TITLE)
case "move_to_bottom":
self.move_inline_metadata(location=InsertLocation.BOTTOM)
case _: # pragma: no cover
return
def application_vault(self) -> None:
"""Vault actions."""
alerts.usage("Create or delete a backup of your vault.")
choices = [
questionary.Separator(),
{"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 "list_notes":
self.vault.list_editable_notes()
case "rename_inline_tag":
self.rename_inline_tag()
case "delete_inline_tag":
self.delete_inline_tag()
case "rename_key":
self.rename_key()
case "delete_key":
self.delete_key()
case "rename_value":
self.rename_value()
case "delete_value":
self.delete_value()
case "review_changes":
self.review_changes()
case "commit_changes":
if self.commit_changes():
break
case _:
return
log.error("Commit failed. Please run with -vvv for more info.")
break
def commit_changes(self) -> bool:
"""Write all changes to disk.
case "abort":
break
Returns:
True if changes were committed, False otherwise.
"""
changed_notes = self.vault.get_changed_notes()
print("Done!")
return
if len(changed_notes) == 0:
console.print("\n")
alerts.notice("No changes to commit.\n")
return False
def rename_key(self) -> None:
"""Renames a key in the vault."""
backup = questionary.confirm("Create backup before committing changes").ask()
if backup is None:
return False
if backup:
self.vault.backup()
original_key = self.questions.ask_for_existing_key(
question="Which key would you like to rename?"
)
if original_key is None:
return
if questionary.confirm(f"Commit {len(changed_notes)} changed files to disk?").ask():
self.vault.commit_changes()
new_key = self.questions.ask_for_new_key()
if new_key is None:
return
if not self.dry_run:
alerts.success(f"{len(changed_notes)} changes committed to disk. Exiting")
raise typer.Exit(0)
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."""
original_tag = self.questions.ask_for_existing_inline_tag(question="Which tag to rename?")
if original_tag is None:
return
new_tag = self.questions.ask_for_new_tag("New tag")
if new_tag is None:
return
num_changed = self.vault.rename_inline_tag(original_tag, new_tag)
if num_changed == 0:
alerts.warning(f"No notes were changed")
return
alerts.success(
f"Renamed [reverse]{original_tag}[/] to [reverse]{new_tag}[/] in {num_changed} notes"
)
return
return True
def delete_inline_tag(self) -> None:
"""Delete an inline tag."""
tag = self.questions.ask_for_existing_inline_tag(
question="Which tag would you like to delete?"
)
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")
alerts.warning("No notes were changed")
return
alerts.success(f"Deleted inline tag: {tag} in {num_changed} notes")
@@ -145,10 +496,10 @@ class Application:
def delete_key(self) -> None:
"""Delete a key from the vault."""
key_to_delete = self.questions.ask_for_existing_keys_regex(
key_to_delete = self.questions.ask_existing_keys_regex(
question="Regex for the key(s) you'd like to delete?"
)
if key_to_delete is None:
if key_to_delete is None: # pragma: no cover
return
num_changed = self.vault.delete_metadata(key_to_delete)
@@ -162,43 +513,15 @@ class Application:
return
def rename_value(self) -> None:
"""Rename a value in the vault."""
key = self.questions.ask_for_existing_key(
question="Which key contains the value to rename?"
)
if key is None:
return
question_key = Questions(vault=self.vault, key=key)
value = question_key.ask_for_existing_value(
question="Which value would you like to rename?"
)
if value is None:
return
new_value = question_key.ask_for_new_value()
if new_value is None:
return
num_changes = self.vault.rename_metadata(key, value, new_value)
if num_changes == 0:
alerts.warning(f"No notes were changed")
return
alerts.success(f"Renamed '{key}:{value}' to '{key}:{new_value}' in {num_changes} notes")
def delete_value(self) -> None:
"""Delete a value from the vault."""
key = self.questions.ask_for_existing_key(
question="Which key contains the value to delete?"
)
if key is None:
key = self.questions.ask_existing_key(question="Which key contains the value to delete?")
if key is None: # pragma: no cover
return
questions2 = Questions(vault=self.vault, key=key)
value = questions2.ask_for_existing_value_regex(question="Regex for the value to delete")
if value is None:
value = questions2.ask_existing_value_regex(question="Regex for the value to delete")
if value is None: # pragma: no cover
return
num_changed = self.vault.delete_metadata(key, value)
@@ -212,6 +535,90 @@ class Application:
return
def move_inline_metadata(self, location: InsertLocation) -> None:
"""Move inline metadata to the selected location."""
num_changed = self.vault.move_inline_metadata(location)
if num_changed == 0:
alerts.warning("No notes were changed")
return
alerts.success(f"Moved inline metadata to {location.value} in {num_changed} notes")
def noninteractive_export_csv(self, path: Path) -> None:
"""Export the vault metadata to CSV."""
self._load_vault()
self.vault.export_metadata(export_format="json", path=str(path))
alerts.success(f"Exported metadata to {path}")
def noninteractive_export_json(self, path: Path) -> None:
"""Export the vault metadata to JSON."""
self._load_vault()
self.vault.export_metadata(export_format="json", path=str(path))
alerts.success(f"Exported metadata to {path}")
def rename_key(self) -> None:
"""Rename a key in the vault."""
original_key = self.questions.ask_existing_key(
question="Which key would you like to rename?"
)
if original_key is None: # pragma: no cover
return
new_key = self.questions.ask_new_key()
if new_key is None: # pragma: no cover
return
num_changed = self.vault.rename_metadata(original_key, new_key)
if num_changed == 0:
alerts.warning("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."""
original_tag = self.questions.ask_existing_inline_tag(question="Which tag to rename?")
if original_tag is None: # pragma: no cover
return
new_tag = self.questions.ask_new_tag("New tag")
if new_tag is None: # pragma: no cover
return
num_changed = self.vault.rename_inline_tag(original_tag, new_tag)
if num_changed == 0:
alerts.warning("No notes were changed")
return
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 = self.questions.ask_existing_key(question="Which key contains the value to rename?")
if key is None: # pragma: no cover
return
question_key = Questions(vault=self.vault, key=key)
value = question_key.ask_existing_value(question="Which value would you like to rename?")
if value is None: # pragma: no cover
return
new_value = question_key.ask_new_value()
if new_value is None: # pragma: no cover
return
num_changes = self.vault.rename_metadata(key, value, new_value)
if num_changes == 0:
alerts.warning("No notes were changed")
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."""
changed_notes = self.vault.get_changed_notes()
@@ -220,14 +627,9 @@ class Application:
alerts.info("No changes to review.")
return
print(f"\nFound {len(changed_notes)} changed notes in the vault.\n")
answer = self.questions.ask_confirm(
question="View diffs of individual files?", default=False
)
if not answer:
return
choices: list[dict[str, Any] | questionary.Separator] = [questionary.Separator()]
alerts.info(f"Found {len(changed_notes)} changed notes in the vault")
choices: list[dict[str, Any] | questionary.Separator] = []
choices.append(questionary.Separator())
for n, note in enumerate(changed_notes, start=1):
_selection = {
"name": f"{n}: {note.note_path.relative_to(self.vault.vault_path)}",
@@ -239,37 +641,84 @@ class Application:
choices.append({"name": "Return", "value": "return"})
while True:
note_to_review = self.questions.ask_for_selection(
note_to_review = self.questions.ask_selection(
choices=choices,
question="Select a new to view the diff",
question="Select an updated note 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) -> bool:
"""Write all changes to disk.
def transpose_metadata(self, begin: MetadataType, end: MetadataType) -> None: # noqa: PLR0911
"""Transpose metadata from one format to another.
Returns:
True if changes were committed, False otherwise.
Args:
begin: The format to transpose from.
end: The format to transpose to.
"""
changed_notes = self.vault.get_changed_notes()
choices = [
{"name": f"Transpose all {begin.value} to {end.value}", "value": "transpose_all"},
{"name": "Transpose a key", "value": "transpose_key"},
{"name": "Transpose a value", "value": "transpose_value"},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
match self.questions.ask_selection(choices=choices, question="Select an action to perform"):
case "transpose_all":
num_changed = self.vault.transpose_metadata(
begin=begin,
end=end,
location=self.vault.insert_location,
)
if len(changed_notes) == 0:
print("\n")
alerts.notice("No changes to commit.\n")
return False
if num_changed == 0:
alerts.warning("No notes were changed")
return
backup = questionary.confirm("Create backup before committing changes").ask()
if backup is None:
return False
if backup:
self.vault.backup()
alerts.success(f"Transposed {begin.value} to {end.value} in {num_changed} notes")
case "transpose_key":
key = self.questions.ask_existing_key(question="Which key to transpose?")
if key is None: # pragma: no cover
return
if questionary.confirm(f"Commit {len(changed_notes)} changed files to disk?").ask():
num_changed = self.vault.transpose_metadata(
begin=begin,
end=end,
key=key,
location=self.vault.insert_location,
)
self.vault.write()
alerts.success(f"{len(changed_notes)} changes committed to disk. Exiting")
return True
if num_changed == 0:
alerts.warning("No notes were changed")
return
return False
alerts.success(
f"Transposed key: `{key}` from {begin.value} to {end.value} in {num_changed} notes"
)
case "transpose_value":
key = self.questions.ask_existing_key(question="Which key contains the value?")
if key is None: # pragma: no cover
return
questions2 = Questions(vault=self.vault, key=key)
value = questions2.ask_existing_value(question="Which value to transpose?")
if value is None: # pragma: no cover
return
num_changed = self.vault.transpose_metadata(
begin=begin,
end=end,
key=key,
value=value,
location=self.vault.insert_location,
)
if num_changed == 0:
alerts.warning("No notes were changed")
return
alerts.success(
f"Transposed key: `{key}:{value}` from {begin.value} to {end.value} in {num_changed} notes"
)
case _:
return

View File

@@ -0,0 +1,27 @@
"""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"
KEYS = "Metadata Keys Only"
ALL = "All Metadata"
class InsertLocation(Enum):
"""Location to add metadata to notes.
TOP: Directly after frontmatter.
AFTER_TITLE: After a header following frontmatter.
BOTTOM: The bottom of the note
"""
TOP = "Top"
AFTER_TITLE = "After title"
BOTTOM = "Bottom"

View File

@@ -1,11 +1,10 @@
"""Work with metadata items."""
import copy
import re
from io import StringIO
from rich import print
from rich.columns import Columns
from rich.console import Console
from rich.table import Table
from ruamel.yaml import YAML
@@ -13,12 +12,15 @@ from obsidian_metadata._utils import (
clean_dictionary,
dict_contains,
dict_values_to_lists_strings,
merge_dictionaries,
remove_markdown_sections,
)
from obsidian_metadata._utils.console import console
from obsidian_metadata.models import Patterns # isort: ignore
from obsidian_metadata.models.enums import MetadataType
PATTERNS = Patterns()
INLINE_TAG_KEY: str = "Inline Tags"
INLINE_TAG_KEY: str = "inline_tag"
class VaultMetadata:
@@ -26,82 +28,81 @@ class VaultMetadata:
def __init__(self) -> None:
self.dict: dict[str, list[str]] = {}
self.frontmatter: dict[str, list[str]] = {}
self.inline_metadata: dict[str, list[str]] = {}
self.tags: list[str] = []
def __repr__(self) -> str:
"""Representation of all metadata."""
return str(self.dict)
def add_metadata(self, metadata: dict[str, list[str]]) -> None:
"""Add metadata to the vault. Takes a dictionary as input and merges it with the existing metadata. Does not overwrite existing keys.
def index_metadata(
self, area: MetadataType, metadata: dict[str, list[str]] | list[str]
) -> None:
"""Index pre-existing metadata in the vault. Takes a dictionary as input and merges it with the existing metadata. Does not overwrite existing keys.
Args:
area (MetadataType): Type of metadata.
metadata (dict): Metadata to add.
"""
existing_metadata = self.dict
if isinstance(metadata, dict):
new_metadata = clean_dictionary(metadata)
self.dict = merge_dictionaries(self.dict, new_metadata)
new_metadata = clean_dictionary(metadata)
if area == MetadataType.FRONTMATTER:
self.frontmatter = merge_dictionaries(self.frontmatter, new_metadata)
for k, v in new_metadata.items():
if k in existing_metadata:
if isinstance(v, list):
existing_metadata[k].extend(v)
else:
existing_metadata[k] = v
if area == MetadataType.INLINE:
self.inline_metadata = merge_dictionaries(self.inline_metadata, new_metadata)
for k, v in existing_metadata.items():
if isinstance(v, list):
existing_metadata[k] = sorted(set(v))
elif isinstance(v, dict):
for kk, vv in v.items():
if isinstance(vv, list):
v[kk] = sorted(set(vv))
if area == MetadataType.TAGS and isinstance(metadata, list):
self.tags.extend(metadata)
self.tags = sorted({s.strip("#") for s in self.tags})
self.dict = dict(sorted(existing_metadata.items()))
def 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:
def contains( # noqa: PLR0911
self, area: MetadataType, key: str = None, value: str = None, is_regex: bool = False
) -> bool:
"""Check if a key and/or a value exists in the metadata.
Args:
key (str): Key to check.
area (MetadataType): Type of metadata to check.
key (str, optional): Key to check.
value (str, optional): Value to check.
is_regex (bool, optional): Use regex to check. Defaults to False.
Returns:
bool: True if the key exists.
Raises:
ValueError: Key must be provided when checking for a key's existence.
ValueError: Value must be provided when checking for a tag's existence.
"""
return dict_contains(self.dict, key, value, is_regex)
if area != MetadataType.TAGS and key is None:
raise ValueError("Key must be provided when checking for a key's existence.")
match area:
case MetadataType.ALL:
if dict_contains(self.dict, key, value, is_regex):
return True
if key is None and value is not None:
if is_regex:
return any(re.search(value, tag) for tag in self.tags)
return value in self.tags
case MetadataType.FRONTMATTER:
return dict_contains(self.frontmatter, key, value, is_regex)
case MetadataType.INLINE:
return dict_contains(self.inline_metadata, key, value, is_regex)
case MetadataType.KEYS:
return dict_contains(self.dict, key, value, is_regex)
case MetadataType.TAGS:
if value is None:
raise ValueError("Value must be provided when checking for a tag's existence.")
if is_regex:
return any(re.search(value, tag) for tag in self.tags)
return value in self.tags
return False
def delete(self, key: str, value_to_delete: str = None) -> bool:
"""Delete a key or a key's value from the metadata. Regex is supported to allow deleting more than one key or value.
@@ -113,7 +114,7 @@ class VaultMetadata:
Returns:
bool: True if a value was deleted
"""
new_dict = self.dict.copy()
new_dict = copy.deepcopy(self.dict)
if value_to_delete is None:
for _k in list(new_dict):
@@ -131,6 +132,56 @@ class VaultMetadata:
return False
def print_metadata(self, area: MetadataType) -> None:
"""Print metadata to the terminal.
Args:
area (MetadataType): Type of metadata to print
"""
dict_to_print: dict[str, list[str]] = None
list_to_print: list[str] = None
match area:
case MetadataType.INLINE:
dict_to_print = self.inline_metadata.copy()
header = "All inline metadata"
case MetadataType.FRONTMATTER:
dict_to_print = self.frontmatter.copy()
header = "All frontmatter"
case MetadataType.TAGS:
list_to_print = []
for tag in self.tags:
list_to_print.append(f"#{tag}")
header = "All inline tags"
case MetadataType.KEYS:
list_to_print = sorted(self.dict.keys())
header = "All Keys"
case MetadataType.ALL:
dict_to_print = self.dict.copy()
list_to_print = []
for tag in self.tags:
list_to_print.append(f"#{tag}")
header = "All metadata"
if dict_to_print is not None:
table = Table(title=header, show_footer=False, show_lines=True)
table.add_column("Keys")
table.add_column("Values")
for key, value in sorted(dict_to_print.items()):
values: str | dict[str, list[str]] = (
"\n".join(sorted(value)) if isinstance(value, list) else value
)
table.add_row(f"[bold]{key}[/]", str(values))
console.print(table)
if list_to_print is not None:
columns = Columns(
sorted(list_to_print),
equal=True,
expand=True,
title=header if area != MetadataType.ALL else "All inline tags",
)
console.print(columns)
def rename(self, key: str, value_1: str, value_2: str = None) -> bool:
"""Replace a value in the frontmatter.
@@ -159,10 +210,9 @@ class VaultMetadata:
class Frontmatter:
"""Representation of frontmatter metadata."""
def __init__(self, file_content: str):
def __init__(self, file_content: str) -> None:
self.dict: dict[str, list[str]] = self._grab_note_frontmatter(file_content)
self.dict_original: dict[str, list[str]] = self.dict.copy()
self.dict_original: dict[str, list[str]] = copy.deepcopy(self.dict)
def __repr__(self) -> str: # pragma: no cover
"""Representation of the frontmatter.
@@ -176,20 +226,27 @@ class Frontmatter:
"""Grab metadata from a note.
Args:
note_path (Path): Path to the note file.
file_content (str): Content of the note.
Returns:
dict: Metadata from the note.
"""
try:
frontmatter_block: str = PATTERNS.frontmatt_block_no_separators.search(
frontmatter_block: str = PATTERNS.frontmatt_block_strip_separators.search(
file_content
).group("frontmatter")
except AttributeError:
return {}
yaml = YAML(typ="safe")
frontmatter: dict = yaml.load(frontmatter_block)
yaml.allow_unicode = False
try:
frontmatter: dict = yaml.load(frontmatter_block)
except Exception as e: # noqa: BLE001
raise AttributeError(e) from e
if frontmatter is None or frontmatter == [None]:
return {}
for k in frontmatter:
if frontmatter[k] is None:
@@ -197,6 +254,41 @@ class Frontmatter:
return dict_values_to_lists_strings(frontmatter, strip_null_values=True)
def add(self, key: str, value: str | list[str] = None) -> bool: # noqa: PLR0911
"""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)
self.dict[key] = list(sorted(set(self.dict[key])))
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,6 +302,46 @@ class Frontmatter:
"""
return dict_contains(self.dict, key, value, is_regex)
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.
Args:
key (str): If no value, key to delete. If value, key containing the value.
value_to_delete (str, optional): Value to delete.
Returns:
bool: True if a value was deleted
"""
new_dict = copy.deepcopy(self.dict)
if value_to_delete is None:
for _k in list(new_dict):
if re.search(key, _k):
del new_dict[_k]
else:
for _k, _v in new_dict.items():
if re.search(key, _k):
new_values = [x for x in _v if not re.search(value_to_delete, x)]
new_dict[_k] = sorted(new_values)
if new_dict != self.dict:
self.dict = dict(new_dict)
return True
return False
def delete_all(self) -> None:
"""Delete all Frontmatter from the note."""
self.dict = {}
def has_changes(self) -> bool:
"""Check if the frontmatter has changes.
Returns:
bool: True if the frontmatter has changes.
"""
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.
@@ -233,42 +365,6 @@ class Frontmatter:
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.
Args:
key (str): If no value, key to delete. If value, key containing the value.
value_to_delete (str, optional): Value to delete.
Returns:
bool: True if a value was deleted
"""
new_dict = dict(self.dict)
if value_to_delete is None:
for _k in list(new_dict):
if re.search(key, _k):
del new_dict[_k]
else:
for _k, _v in new_dict.items():
if re.search(key, _k):
new_values = [x for x in _v if not re.search(value_to_delete, x)]
new_dict[_k] = sorted(new_values)
if new_dict != self.dict:
self.dict = dict(new_dict)
return True
return False
def has_changes(self) -> bool:
"""Check if the frontmatter has changes.
Returns:
bool: True if the frontmatter has changes.
"""
return self.dict != self.dict_original
def to_yaml(self, sort_keys: bool = False) -> str:
"""Return the frontmatter as a YAML string.
@@ -276,7 +372,7 @@ class Frontmatter:
str: Frontmatter as a YAML string.
sort_keys (bool, optional): Sort the keys. Defaults to False.
"""
dict_to_dump = self.dict.copy()
dict_to_dump = copy.deepcopy(self.dict)
for k in dict_to_dump:
if dict_to_dump[k] == []:
dict_to_dump[k] = None
@@ -301,10 +397,9 @@ class Frontmatter:
class InlineMetadata:
"""Representation of inline metadata in the form of `key:: value`."""
def __init__(self, file_content: str):
def __init__(self, file_content: str) -> None:
self.dict: dict[str, list[str]] = self._grab_inline_metadata(file_content)
self.dict_original: dict[str, list[str]] = self.dict.copy()
self.dict_original: dict[str, list[str]] = copy.deepcopy(self.dict)
def __repr__(self) -> str: # pragma: no cover
"""Representation of inline metadata.
@@ -330,7 +425,7 @@ class InlineMetadata:
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
inline_metadata: dict[str, list[str]] = {}
for (k, v) in stripped_null_values:
for k, v in stripped_null_values:
if k in inline_metadata:
inline_metadata[k].append(str(v))
else:
@@ -338,6 +433,41 @@ class InlineMetadata:
return clean_dictionary(inline_metadata)
def add(self, key: str, value: str | list[str] = None) -> bool: # noqa: PLR0911
"""Add a key and value to the inline metadata.
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)
self.dict[key] = list(sorted(set(self.dict[key])))
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 inline metadata.
@@ -351,29 +481,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,12 +517,34 @@ 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."""
def __init__(self, file_content: str):
def __init__(self, file_content: str) -> None:
self.metadata_key = INLINE_TAG_KEY
self.list: list[str] = self._grab_inline_tags(file_content)
self.list_original: list[str] = self.list.copy()
@@ -447,6 +576,37 @@ class InlineTags:
)
)
def add(self, new_tag: str | list[str]) -> bool:
"""Add a new inline tag.
Args:
new_tag (str): Tag to add.
Returns:
bool: True if a tag was added.
"""
if isinstance(new_tag, list):
for _tag in new_tag:
if _tag.startswith("#"):
_tag = _tag[1:]
if _tag in self.list:
return False
new_list = self.list.copy()
new_list.append(_tag)
self.list = sorted(new_list)
return True
else:
if new_tag.startswith("#"):
new_tag = new_tag[1:]
if new_tag in self.list:
return False
new_list = self.list.copy()
new_list.append(new_tag)
self.list = sorted(new_list)
return True
return False
def contains(self, tag: str, is_regex: bool = False) -> bool:
"""Check if a tag exists in the metadata.
@@ -465,21 +625,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 +648,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

@@ -1,19 +1,24 @@
"""Representation of notes and in the vault."""
"""Representation of a not in the vault."""
import copy
import difflib
import re
from pathlib import Path
import rich.repr
import typer
from rich.table import Table
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata._utils.console import console
from obsidian_metadata.models import (
Frontmatter,
InlineMetadata,
InlineTags,
InsertLocation,
MetadataType,
Patterns,
)
@@ -36,7 +41,7 @@ class Note:
inline_metadata (dict): Dictionary of inline metadata in the note.
"""
def __init__(self, note_path: Path, dry_run: bool = False):
def __init__(self, note_path: Path, dry_run: bool = False) -> None:
log.trace(f"Creating Note object for {note_path}")
self.note_path: Path = Path(note_path)
self.dry_run: bool = dry_run
@@ -48,7 +53,12 @@ class Note:
alerts.error(f"Note {self.note_path} not found. Exiting")
raise typer.Exit(code=1) from e
self.frontmatter: Frontmatter = Frontmatter(self.file_content)
try:
self.frontmatter: Frontmatter = Frontmatter(self.file_content)
except AttributeError as e:
alerts.error(f"Note {self.note_path} has invalid frontmatter.\n{e}")
raise typer.Exit(code=1) from e
self.inline_tags: InlineTags = InlineTags(self.file_content)
self.inline_metadata: InlineMetadata = InlineMetadata(self.file_content)
self.original_file_content: str = self.file_content
@@ -61,23 +71,87 @@ class Note:
yield "inline_tags", self.inline_tags
yield "inline_metadata", self.inline_metadata
def append(self, string_to_append: str, allow_multiple: bool = False) -> None:
"""Appends a string to the end of a note.
def add_metadata( # noqa: C901
self,
area: MetadataType,
key: str = None,
value: str | list[str] = None,
location: InsertLocation = None,
) -> bool:
"""Add metadata to the note if it does not already exist. This method adds specified metadata to the appropriate MetadataType object AND writes the new metadata to the note's file.
Args:
string_to_append (str): String to append to the note.
allow_multiple (bool): Whether to allow appending the string if it already exists in the note.
"""
if allow_multiple:
self.file_content += f"\n{string_to_append}"
else:
if len(re.findall(re.escape(string_to_append), self.file_content)) == 0:
self.file_content += f"\n{string_to_append}"
area (MetadataType): Area to add metadata to.
key (str, optional): Key to add
location (InsertLocation, optional): Location to add inline metadata and tags.
value (str, optional): Value to add.
def commit_changes(self) -> None:
"""Commits changes to the note to disk."""
# TODO: rewrite frontmatter if it has changed
pass
Returns:
bool: Whether the metadata was added.
"""
match area:
case MetadataType.FRONTMATTER if self.frontmatter.add(key, value):
self.write_frontmatter()
return True
case MetadataType.INLINE:
if value is None and self.inline_metadata.add(key):
line = f"{key}::"
self.write_string(new_string=line, location=location)
return True
new_values = []
if isinstance(value, list):
new_values = [_v for _v in value if self.inline_metadata.add(key, _v)]
elif self.inline_metadata.add(key, value):
new_values = [value]
if new_values:
for value in new_values:
self.write_string(new_string=f"{key}:: {value}", location=location)
return True
case MetadataType.TAGS:
new_values = []
if isinstance(value, list):
new_values = [_v for _v in value if self.inline_tags.add(_v)]
elif self.inline_tags.add(value):
new_values = [value]
if new_values:
for value in new_values:
_v = value
if _v.startswith("#"):
_v = _v[1:]
self.write_string(new_string=f"#{_v}", location=location)
return True
case _:
return False
return False
def commit(self, path: Path = None) -> None:
"""Write the note's new content to disk. This is a destructive action.
Args:
path (Path): Path to write the note to. Defaults to the note's path.
Raises:
typer.Exit: If the note's path is not found.
"""
p = self.note_path if path is None else path
if self.dry_run:
log.trace(f"DRY RUN: Writing note {p} to disk")
return
try:
with p.open(mode="w") as f:
log.trace(f"Writing note {p} to disk")
f.write(self.file_content)
except FileNotFoundError as e:
alerts.error(f"Note {p} not found. Exiting")
raise typer.Exit(code=1) from e
def contains_inline_tag(self, tag: str, is_regex: bool = False) -> bool:
"""Check if a note contains the specified inline tag.
@@ -92,7 +166,7 @@ class Note:
return self.inline_tags.contains(tag, is_regex=is_regex)
def contains_metadata(self, key: str, value: str = None, is_regex: bool = False) -> bool:
"""Check if a note has a key or a key-value pair in its metadata.
"""Check if a note has a key or a key-value pair in its Frontmatter or InlineMetadata.
Args:
key (str): Key to check for.
@@ -116,31 +190,19 @@ 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.
def delete_all_metadata(self) -> None:
"""Delete all metadata from the note. Removes all frontmatter and inline metadata and tags from the body of the note and from the associated metadata objects."""
for key in self.inline_metadata.dict:
self.delete_metadata(key=key, area=MetadataType.INLINE)
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 tag in self.inline_tags.list:
self.delete_inline_tag(tag=tag)
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)
self.frontmatter.delete_all()
self.write_frontmatter()
def delete_inline_tag(self, tag: str) -> bool:
"""Deletes an inline tag from the `inline_tags` attribute AND removes the tag from the text of the note if it exists.
"""Delete an inline tag from the `inline_tags` attribute AND removes the tag from the text of the note if it exists.
Args:
tag (str): Tag to delete.
@@ -161,41 +223,42 @@ class Note:
return False
def delete_metadata(self, key: str, value: str = None) -> bool:
"""Deletes a key or key-value pair from the note's metadata. Regex is supported.
def delete_metadata(
self, key: str, value: str = None, area: MetadataType = MetadataType.ALL
) -> bool:
"""Delete a key or key-value pair from the note's Metadata object and the content of the note. Regex is supported.
If no value is provided, will delete an entire key.
If no value is provided, will delete an entire specified key.
Args:
key (str): Key to delete.
value (str, optional): Value to delete.
area (MetadataType, optional): Area to delete metadata from. Defaults to MetadataType.ALL.
Returns:
bool: Whether the key or key-value pair was deleted.
"""
changed_value: bool = False
if value is None:
if self.frontmatter.delete(key):
self.replace_frontmatter()
changed_value = True
if self.inline_metadata.delete(key):
self._delete_inline_metadata(key, value)
changed_value = True
else:
if self.frontmatter.delete(key, value):
self.replace_frontmatter()
changed_value = True
if self.inline_metadata.delete(key, value):
self._delete_inline_metadata(key, value)
changed_value = True
if (
area == MetadataType.FRONTMATTER or area == MetadataType.ALL
) and self.frontmatter.delete(key, value):
self.write_frontmatter()
changed_value = True
if (
area == MetadataType.INLINE or area == MetadataType.ALL
) and self.inline_metadata.contains(key, value):
self.write_delete_inline_metadata(key, value)
self.inline_metadata.delete(key, value)
changed_value = True
if changed_value:
return True
return False
def has_changes(self) -> bool:
"""Checks if the note has been updated.
"""Check if the note has been updated.
Returns:
bool: Whether the note has been updated.
@@ -214,68 +277,29 @@ class Note:
return False
def print_note(self) -> None:
"""Prints the note to the console."""
print(self.file_content)
def print_diff(self) -> None:
"""Prints a diff of the note's original state and it's new state."""
"""Print a diff of the note's content. Compares original state to it's new state."""
a = self.original_file_content.splitlines()
b = self.file_content.splitlines()
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"\033[92m{line}\033[0m")
table.add_row(line, style="green")
elif line.startswith("-"):
print(f"\033[91m{line}\033[0m")
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)
self.file_content = re.sub(pattern, replacement, self.file_content, re.MULTILINE)
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 print_note(self) -> None:
"""Print the note to the console."""
console.print(self.file_content)
def rename_inline_tag(self, tag_1: str, tag_2: str) -> bool:
"""Renames an inline tag from the note ONLY if it's not in the metadata as well.
"""Rename an inline tag. Updates the Metadata object and the text of the note.
Args:
tag_1 (str): Tag to rename.
@@ -295,9 +319,9 @@ class Note:
return False
def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> bool:
"""Renames a key or key-value pair in the note's metadata.
"""Rename a key or key-value pair in the note's InlineMetadata and Frontmatter objects and the content of the note.
If no value is provided, will rename an entire key.
If no value is provided, will rename the entire specified key.
Args:
key (str): Key to rename.
@@ -310,17 +334,17 @@ class Note:
changed_value: bool = False
if value_2 is None:
if self.frontmatter.rename(key, value_1):
self.replace_frontmatter()
self.write_frontmatter()
changed_value = True
if self.inline_metadata.rename(key, value_1):
self._rename_inline_metadata(key, value_1)
self.write_inline_metadata_change(key, value_1)
changed_value = True
else:
if self.frontmatter.rename(key, value_1, value_2):
self.replace_frontmatter()
self.write_frontmatter()
changed_value = True
if self.inline_metadata.rename(key, value_1, value_2):
self._rename_inline_metadata(key, value_1, value_2)
self.write_inline_metadata_change(key, value_1, value_2)
changed_value = True
if changed_value:
@@ -328,43 +352,265 @@ class Note:
return False
def replace_frontmatter(self, sort_keys: bool = False) -> None:
"""Replaces the frontmatter in the note with the current frontmatter object."""
def sub(self, pattern: str, replacement: str, is_regex: bool = False) -> None:
"""Substitutes text within the note.
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)
self.file_content = re.sub(pattern, replacement, self.file_content, re.MULTILINE)
def transpose_metadata( # noqa: C901, PLR0912, PLR0911
self,
begin: MetadataType,
end: MetadataType,
key: str = None,
value: str | list[str] = None,
location: InsertLocation = InsertLocation.BOTTOM,
) -> bool:
"""Move metadata from one metadata object to another. i.e. Frontmatter to InlineMetadata or vice versa.
If no key is specified, will transpose all metadata. If a key is specified, but no value, the entire key will be transposed. if a specific value is specified, just that value will be transposed.
Args:
begin (MetadataType): The type of metadata to transpose from.
end (MetadataType): The type of metadata to transpose to.
key (str, optional): The key to transpose. Defaults to None.
location (InsertLocation, optional): Where to insert the metadata. Defaults to InsertLocation.BOTTOM.
value (str | list[str], optional): The value to transpose. Defaults to None.
Returns:
bool: Whether the note was updated.
"""
if (begin == MetadataType.FRONTMATTER or begin == MetadataType.INLINE) and (
end == MetadataType.FRONTMATTER or end == MetadataType.INLINE
):
if begin == MetadataType.FRONTMATTER:
begin_dict = self.frontmatter.dict
else:
begin_dict = self.inline_metadata.dict
if begin_dict == {}:
return False
if key is None: # Transpose all metadata when no key is provided
for _key, _value in begin_dict.items():
self.add_metadata(key=_key, value=_value, area=end, location=location)
self.delete_metadata(key=_key, area=begin)
return True
has_changes = False
temp_dict = copy.deepcopy(begin_dict)
for k, v in begin_dict.items():
if key == k:
if value is None:
self.add_metadata(key=k, value=v, area=end, location=location)
self.delete_metadata(key=k, area=begin)
return True
if value == v:
self.add_metadata(key=k, value=v, area=end, location=location)
self.delete_metadata(key=k, area=begin)
return True
if isinstance(value, str):
if value in v:
self.add_metadata(key=k, value=value, area=end, location=location)
self.delete_metadata(key=k, value=value, area=begin)
return True
return False
if isinstance(value, list):
for value_item in value:
if value_item in v:
self.add_metadata(
key=k, value=value_item, area=end, location=location
)
self.delete_metadata(key=k, value=value_item, area=begin)
temp_dict[k].remove(value_item)
has_changes = True
if temp_dict[k] == []:
self.delete_metadata(key=k, area=begin)
return bool(has_changes)
if begin == MetadataType.TAGS:
# TODO: Implement transposing to and from tags
pass
return False
def write_delete_inline_metadata(self, key: str = None, value: str = None) -> bool:
"""For a given inline metadata key and/or key-value pair, delete it from the text of the note. If no key is provided, will delete all inline metadata from the text of the note.
IMPORTANT: This method makes no changes to the InlineMetadata object.
Args:
key (str, optional): Key to delete.
value (str, optional): Value to delete.
Returns:
bool: Whether the note was updated.
"""
if self.inline_metadata.dict != {}:
if key is None:
for _k, _v in self.inline_metadata.dict.items():
for _value in _v:
_k = re.escape(_k)
_value = re.escape(_value)
self.sub(rf"\[?{_k}:: ?\[?\[?{_value}\]?\]?", "", is_regex=True)
return True
for _k, _v in self.inline_metadata.dict.items():
if re.search(key, _k):
for _value in _v:
if value is None:
_k = re.escape(_k)
_value = re.escape(_value)
self.sub(rf"\[?{_k}:: \[?\[?{_value}\]?\]?", "", is_regex=True)
elif re.search(value, _value):
_k = re.escape(_k)
_value = re.escape(_value)
self.sub(rf"\[?({_k}::) ?\[?\[?{_value}\]?\]?", r"\1", is_regex=True)
return True
return False
def write_frontmatter(self, sort_keys: bool = False) -> bool:
"""Replace the frontmatter in the note with the current Frontmatter object. If the Frontmatter object is empty, will delete the frontmatter from the note.
Returns:
bool: Whether the note was updated.
"""
try:
current_frontmatter = PATTERNS.frontmatt_block_with_separators.search(
self.file_content
).group("frontmatter")
current_frontmatter = PATTERNS.frontmatter_block.search(self.file_content).group(
"frontmatter"
)
except AttributeError:
current_frontmatter = None
if current_frontmatter is None and self.frontmatter.dict == {}:
return
return False
new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys)
new_frontmatter = f"---\n{new_frontmatter}---\n"
new_frontmatter = "" if self.frontmatter.dict == {} else f"---\n{new_frontmatter}---\n"
if current_frontmatter is None:
self.file_content = new_frontmatter + self.file_content
return
return True
current_frontmatter = re.escape(current_frontmatter)
current_frontmatter = f"{re.escape(current_frontmatter)}\n?"
self.sub(current_frontmatter, new_frontmatter, is_regex=True)
return True
def write(self, path: Path = None) -> None:
"""Writes the note's content to disk.
def write_all_inline_metadata(
self,
location: InsertLocation,
) -> bool:
"""Write all metadata found in the InlineMetadata object to the note at a specified insert location.
Args:
path (Path): Path to write the note to. Defaults to the note's path.
location (InsertLocation): Where to insert the metadata.
Raises:
typer.Exit: If the note's path is not found.
Returns:
bool: Whether the note was updated.
"""
p = self.note_path if path is None else path
if self.inline_metadata.dict != {}:
string = ""
for k, v in sorted(self.inline_metadata.dict.items()):
for value in v:
string += f"{k}:: {value}\n"
try:
with open(p, "w") as f:
log.trace(f"Writing note {p} to disk")
f.write(self.file_content)
except FileNotFoundError as e:
alerts.error(f"Note {p} not found. Exiting")
raise typer.Exit(code=1) from e
if self.write_string(new_string=string, location=location, allow_multiple=True):
return True
return False
def write_inline_metadata_change(self, key: str, value_1: str, value_2: str = None) -> None:
"""Write changes to a specific inline metadata key or value.
Args:
key (str): Key to rename.
value_1 (str): Value to replace OR new key name (if value_2 is None).
value_2 (str, optional): New value.
"""
all_results = PATTERNS.find_inline_metadata.findall(self.file_content)
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
for _k, _v in stripped_null_values:
if re.search(key, _k):
if value_2 is None:
if re.search(rf"{key}[^\\w\\d_-]+", _k):
key_text = re.split(r"[^\\w\\d_-]+$", _k)[0]
key_markdown = re.split(r"^[\\w\\d_-]+", _k)[1]
self.sub(
rf"{key_text}{key_markdown}::",
rf"{value_1}{key_markdown}::",
)
else:
self.sub(f"{_k}::", f"{value_1}::")
elif re.search(key, _k) and re.search(value_1, _v):
_k = re.escape(_k)
_v = re.escape(_v)
self.sub(f"{_k}:: ?{_v}", f"{_k}:: {value_2}", is_regex=True)
def write_string(
self,
new_string: str,
location: InsertLocation,
allow_multiple: bool = False,
) -> bool:
"""Insert a string into the note at a requested location.
Args:
new_string (str): String to insert at the top of the note.
allow_multiple (bool): Whether to allow inserting the string if it already exists in the note.
location (InsertLocation): Location to insert the string.
Returns:
bool: Whether the note was updated.
"""
if not allow_multiple and len(re.findall(re.escape(new_string), self.file_content)) > 0:
return False
match location:
case InsertLocation.BOTTOM:
self.file_content += f"\n{new_string}"
return True
case InsertLocation.TOP:
try:
top = PATTERNS.frontmatter_block.search(self.file_content).group("frontmatter")
except AttributeError:
top = ""
if not top:
self.file_content = f"{new_string}\n{self.file_content}"
return True
new_string = f"{top}\n{new_string}"
top = re.escape(top)
self.sub(top, new_string, is_regex=True)
return True
case InsertLocation.AFTER_TITLE:
try:
top = PATTERNS.top_with_header.search(self.file_content).group("top")
except AttributeError:
top = ""
if not top:
self.file_content = f"{new_string}\n{self.file_content}"
return True
new_string = f"{top}\n{new_string}"
top = re.escape(top)
self.sub(top, new_string, is_regex=True)
return True
case _: # pragma: no cover
raise ValueError(f"Invalid location: {location}")

View File

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

View File

@@ -12,11 +12,21 @@ from typing import Any
import questionary
import typer
from obsidian_metadata.models.enums import InsertLocation, MetadataType
from obsidian_metadata.models.patterns import Patterns
from obsidian_metadata.models.vault import Vault
PATTERNS = Patterns()
# Reset the default style of the questionary prompts qmark
questionary.prompts.checkbox.DEFAULT_STYLE = questionary.Style([("qmark", "")])
questionary.prompts.common.DEFAULT_STYLE = questionary.Style([("qmark", "")])
questionary.prompts.confirm.DEFAULT_STYLE = questionary.Style([("qmark", "")])
questionary.prompts.confirm.DEFAULT_STYLE = questionary.Style([("qmark", "")])
questionary.prompts.path.DEFAULT_STYLE = questionary.Style([("qmark", "")])
questionary.prompts.select.DEFAULT_STYLE = questionary.Style([("qmark", "")])
questionary.prompts.text.DEFAULT_STYLE = questionary.Style([("qmark", "")])
class Questions:
"""Class for asking questions to the user and validating responses with questionary."""
@@ -40,7 +50,7 @@ class Questions:
@staticmethod
def _validate_valid_dir(path: str) -> bool | str:
"""Validates a valid directory.
"""Validate a valid directory.
Returns:
bool | str: True if the path is valid, otherwise a string with the error message.
@@ -63,243 +73,21 @@ class Questions:
"""
self.style = questionary.Style(
[
("separator", "bold fg:#6C6C6C"),
("instruction", "fg:#6C6C6C"),
("highlighted", "bold reverse"),
("qmark", "bold"),
("question", "bold"),
("separator", "fg:#808080"),
("answer", "fg:#FF9D00 bold"),
("instruction", "fg:#808080"),
("highlighted", "bold underline"),
("text", ""),
("pointer", "bold"),
]
)
self.vault = vault
self.key = key
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).ask()
def ask_main_application(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=[
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.style,
).ask()
def ask_for_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,
).ask()
if filter_path_regex is None:
raise typer.Exit(code=1)
return filter_path_regex
def ask_for_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(
"Select an item:",
choices=choices,
use_shortcuts=False,
style=self.style,
).ask()
def ask_for_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,
).ask()
def ask_for_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,
).ask()
def ask_for_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,
).ask()
def ask_for_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,
).ask()
def ask_for_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,
).ask()
def ask_for_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).ask()
def ask_for_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,
).ask()
def ask_for_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,
).ask()
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_existing_inline_tag(self, text: str) -> bool | str:
"""Validates an existing inline tag.
"""Validate an existing inline tag.
Returns:
bool | str: True if the tag is valid, otherwise a string with the error message.
@@ -307,30 +95,41 @@ class Questions:
if len(text) < 1:
return "Tag cannot be empty"
if not self.vault.contains_inline_tag(text):
if not self.vault.metadata.contains(area=MetadataType.TAGS, value=text):
return f"'{text}' does not exist as a tag in the vault"
return True
def _validate_valid_vault_regex(self, text: str) -> bool | str:
"""Validates a valid regex.
def _validate_key_exists(self, text: str) -> bool | str:
"""Validate a valid key.
Returns:
bool | str: True if the regex is valid, otherwise a string with the error message.
bool | str: True if the key is valid, otherwise a string with the error message.
"""
if len(text) < 1:
return "Regex cannot be empty"
return "Key cannot be empty"
if not self.vault.metadata.contains(area=MetadataType.KEYS, key=text):
return f"'{text}' does not exist as a key in the vault"
return True
def _validate_key_exists_regex(self, text: str) -> bool | str:
"""Validate 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 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"
if not self.vault.metadata.contains(area=MetadataType.KEYS, key=text, is_regex=True):
return f"'{text}' does not exist as a key in the vault"
return True
@@ -368,8 +167,8 @@ class Questions:
return True
def _validate_value(self, text: str) -> bool | str:
"""Validate the value.
def _validate_new_value(self, text: str) -> bool | str:
"""Validate a new value.
Args:
text (str): The value to validate.
@@ -380,7 +179,78 @@ class Questions:
if len(text) < 1:
return "Value cannot be empty"
if self.key is not None and not self.vault.metadata.contains(self.key, text):
if self.key is not None and self.vault.metadata.contains(
area=MetadataType.ALL, key=self.key, value=text
):
return f"{self.key}:{text} already exists"
return True
def _validate_number(self, text: str) -> bool | str:
"""Validate a number.
Args:
text (str): The number to validate.
Returns:
bool | str: True if the number is valid, otherwise a string with the error message.
"""
if not text.isdigit():
return "Must be an integer"
return True
def _validate_path_is_file(self, text: str) -> bool | str:
"""Validate a path is a file.
Args:
text (str): The path to validate.
Returns:
bool | str: True if the path is valid, otherwise a string with the error message.
"""
path_to_validate: Path = Path(text).expanduser().resolve()
if not path_to_validate.exists():
return f"Path does not exist: {path_to_validate}"
if not path_to_validate.is_file():
return f"Path is not a file: {path_to_validate}"
return True
def _validate_valid_vault_regex(self, text: str) -> bool | str:
"""Validate 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) == 0:
return True
if self.key is not None and not self.vault.metadata.contains(
area=MetadataType.ALL, key=self.key, value=text
):
return f"{self.key}:{text} does not exist"
return True
@@ -402,24 +272,267 @@ class Questions:
except re.error as error:
return f"Invalid regex: {error}"
if self.key is not None and not self.vault.metadata.contains(self.key, text, is_regex=True):
if self.key is not None and not self.vault.metadata.contains(
area=MetadataType.ALL, key=self.key, value=text, is_regex=True
):
return f"No values in {self.key} match regex: {text}"
return True
def _validate_new_value(self, text: str) -> bool | str:
"""Validate a new value.
def ask_application_main(self) -> str: # pragma: no cover
"""List for the main application interface.
Args:
text (str): The value to validate.
style (questionary.Style): The style to use for the question.
Returns:
bool | str: True if the value is valid, otherwise a string with the error message.
str: The selected application.
"""
if len(text) < 1:
return "Value cannot be empty"
return questionary.select(
"What do you want to do?",
choices=[
questionary.Separator("-------------------------------"),
{"name": "Vault Actions", "value": "vault_actions"},
{"name": "Export Metadata", "value": "export_metadata"},
{"name": "Inspect Metadata", "value": "inspect_metadata"},
{"name": "Filter Notes in Scope", "value": "filter_notes"},
questionary.Separator("-------------------------------"),
{"name": "Bulk changes from imported CSV", "value": "import_from_csv"},
{"name": "Add Metadata", "value": "add_metadata"},
{"name": "Delete Metadata", "value": "delete_metadata"},
{"name": "Rename Metadata", "value": "rename_metadata"},
{"name": "Reorganize Metadata", "value": "reorganize_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()
if self.key is not None and self.vault.metadata.contains(self.key, text):
return f"{self.key}:{text} already exists"
def ask_area(self) -> MetadataType | str: # pragma: no cover
"""Ask the user for the metadata area to work on.
return True
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_metadata_location(
self, question: str = "Where in a note should we add metadata"
) -> InsertLocation: # pragma: no cover
"""Ask the user for the location within a note to place new metadata.
Returns:
InsertLocation: The location within a note to place new metadata.
"""
choices = []
for metadata_location in InsertLocation:
choices.append({"name": metadata_location.value, "value": metadata_location})
return self.ask_selection(
choices=choices,
question=question,
)
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 = "Enter a new tag") -> str: # pragma: no cover
"""Ask the user for a new tag.
Args:
question (str, optional): The question to ask. Defaults to "Enter a new tag".
"""
return questionary.text(
question, validate=self._validate_new_tag, style=self.style, qmark="INPUT |"
).ask()
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_number(self, question: str = "Enter a number") -> int: # pragma: no cover
"""Ask the user for a number.
Args:
question (str, optional): The question to ask. Defaults to "Enter a number".
Returns:
int: A number.
"""
return questionary.text(
question, validate=self._validate_number, style=self.style, qmark="INPUT |"
).ask()
def ask_path(
self, question: str = "Enter a path", valid_file: bool = False
) -> str: # pragma: no cover
"""Ask the user for a path.
Args:
question (str, optional): The question to ask. Defaults to "Enter a path".
valid_file (bool, optional): Whether the path should be a valid file. Defaults to False.
Returns:
str: A path.
"""
if valid_file:
return questionary.path(
question,
only_directories=False,
style=self.style,
validate=self._validate_path_is_file,
qmark="INPUT |",
).ask()
return questionary.path(question, style=self.style, qmark="INPUT |").ask()
def ask_selection(
self, choices: list[Any], question: str = "Select an option"
) -> Any: # pragma: no cover
"""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

@@ -1,19 +1,35 @@
"""Obsidian vault representation."""
import csv
import json
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import rich.repr
from rich.console import Console
import typer
from rich import box
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Confirm
from rich.table import Table
from obsidian_metadata._config import VaultConfig
from obsidian_metadata._config.config import 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._utils.console import console
from obsidian_metadata.models import InsertLocation, MetadataType, Note, VaultMetadata
@dataclass
class VaultFilter:
"""Vault filters."""
path_filter: str = None
key_filter: str = None
value_filter: str = None
tag_filter: str = None
@rich.repr.auto
@@ -27,17 +43,26 @@ class Vault:
notes (list[Note]): List of all notes in the vault.
"""
def __init__(self, config: VaultConfig, dry_run: bool = False, path_filter: str = None):
def __init__(
self,
config: VaultConfig,
dry_run: bool = False,
filters: list[VaultFilter] = [],
) -> None:
self.config = config.config
self.vault_path: Path = config.path
self.name = self.vault_path.name
self.insert_location: InsertLocation = self._find_insert_location()
self.dry_run: bool = dry_run
self.backup_path: Path = self.vault_path.parent / f"{self.vault_path.name}.bak"
self.exclude_paths: list[Path] = []
self.metadata = VaultMetadata()
self.exclude_paths: list[Path] = []
for p in config.exclude_paths:
self.exclude_paths.append(Path(self.vault_path / p))
self.path_filter = path_filter
self.note_paths = self._find_markdown_notes(path_filter)
self.filters = filters
self.all_note_paths = self._find_markdown_notes()
with Progress(
SpinnerColumn(),
@@ -45,51 +70,165 @@ class Vault:
transient=True,
) as progress:
progress.add_task(description="Processing notes...", total=None)
self.notes: list[Note] = [
Note(note_path=p, dry_run=self.dry_run) for p in self.note_paths
self.all_notes: list[Note] = [
Note(note_path=p, dry_run=self.dry_run) for p in self.all_note_paths
]
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})
self.notes_in_scope = self._filter_notes()
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 "num_notes", self.num_notes()
yield "config", self.config
yield "dry_run", self.dry_run
yield "exclude_paths", self.exclude_paths
yield "filters", self.filters
yield "insert_location", self.insert_location
yield "name", self.name
yield "num_notes_in_scope", len(self.notes_in_scope)
yield "num_notes", len(self.all_notes)
yield "vault_path", self.vault_path
def _find_markdown_notes(self, path_filter: str = None) -> list[Path]:
"""Build list of all markdown files in the vault.
def _filter_notes(self) -> list[Note]:
"""Filter notes by path and metadata using the filters defined in self.filters.
Returns:
list[Note]: List of notes matching the filters.
"""
notes_list = self.all_notes.copy()
for _filter in self.filters:
if _filter.path_filter is not None:
notes_list = [
n
for n in notes_list
if re.search(_filter.path_filter, str(n.note_path.relative_to(self.vault_path)))
]
if _filter.tag_filter is not None:
notes_list = [n for n in notes_list if n.contains_inline_tag(_filter.tag_filter)]
if _filter.key_filter is not None and _filter.value_filter is not None:
notes_list = [
n
for n in notes_list
if n.contains_metadata(_filter.key_filter, _filter.value_filter)
]
if _filter.key_filter is not None and _filter.value_filter is None:
notes_list = [n for n in notes_list if n.contains_metadata(_filter.key_filter)]
return notes_list
def _find_insert_location(self) -> InsertLocation:
"""Find the insert location for a note from the configuration file.
Returns:
InsertLocation: Insert location for the note.
"""
if self.config["insert_location"].upper() == "TOP":
return InsertLocation.TOP
if self.config["insert_location"].upper() == "AFTER_TITLE":
return InsertLocation.AFTER_TITLE
if self.config["insert_location"].upper() == "BOTTOM":
return InsertLocation.BOTTOM
return InsertLocation.BOTTOM
@property
def insert_location(self) -> InsertLocation:
"""Location to insert new or reorganized metadata.
Returns:
InsertLocation: The insert location.
"""
return self._insert_location
@insert_location.setter
def insert_location(self, value: InsertLocation) -> None:
"""Set the insert location for the vault.
Args:
path_filter (str, optional): Regex to filter notes by path.
value (InsertLocation): The insert location to set.
"""
self._insert_location = value
def _find_markdown_notes(self) -> list[Path]:
"""Build list of all markdown files in the vault.
Returns:
list[Path]: List of paths to all matching files in the vault.
"""
notes_list = [
return [
p.resolve()
for p in self.vault_path.glob("**/*")
if p.suffix in [".md", ".MD", ".markdown", ".MARKDOWN"]
and not any(item in p.parents for item in self.exclude_paths)
]
if path_filter is not None:
notes_list = [
p for p in notes_list if re.search(path_filter, str(p.relative_to(self.vault_path)))
]
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_in_scope:
self.metadata.index_metadata(
area=MetadataType.FRONTMATTER, metadata=_note.frontmatter.dict
)
self.metadata.index_metadata(
area=MetadataType.INLINE, metadata=_note.inline_metadata.dict
)
self.metadata.index_metadata(
area=MetadataType.TAGS,
metadata=_note.inline_tags.list,
)
return notes_list
def add_metadata(
self,
area: MetadataType,
key: str = None,
value: str | list[str] = None,
location: InsertLocation = None,
) -> int:
"""Add metadata to all notes in the vault which do not already contain it.
Args:
area (MetadataType): Area of metadata to add to.
key (str): Key to add.
value (str|list, optional): Value to add.
location (InsertLocation, optional): Location to insert metadata. (Defaults to `vault.config.insert_location`)
Returns:
int: Number of notes updated.
"""
if location is None:
location = self.insert_location
num_changed = 0
for _note in self.notes_in_scope:
if _note.add_metadata(area=area, key=key, value=value, location=location):
log.trace(f"Added metadata to {_note.note_path}")
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}")
console.print("\n")
return
try:
@@ -107,33 +246,21 @@ class Vault:
alerts.success(f"Vault backed up to: {self.backup_path}")
def contains_inline_tag(self, tag: str, is_regex: bool = False) -> bool:
"""Check if vault contains the given inline tag.
def commit_changes(self) -> None:
"""Commit changes by writing to disk."""
log.debug("Writing changes to vault...")
if self.dry_run:
for _note in self.notes_in_scope:
if _note.has_changes():
alerts.dryrun(
f"writing changes to {_note.note_path.relative_to(self.vault_path)}"
)
return
Args:
tag (str): Tag to check for.
is_regex (bool, optional): Whether to use regex to match tag.
Returns:
bool: True if tag is found in vault.
"""
return any(_note.contains_inline_tag(tag) for _note in self.notes)
def contains_metadata(self, key: str, value: str = None, is_regex: bool = False) -> bool:
"""Check if vault contains the given metadata.
Args:
key (str): Key to check for. If value is None, will check vault for key.
value (str, optional): Value to check for.
is_regex (bool, optional): Whether to use regex to match key/value.
Returns:
bool: True if tag is found in vault.
"""
if value is None:
return self.metadata.contains(key, is_regex=is_regex)
return self.metadata.contains(key, value, is_regex=is_regex)
for _note in self.notes_in_scope:
if _note.has_changes():
log.trace(f"writing to {_note.note_path}")
_note.commit()
def delete_backup(self) -> None:
"""Delete the vault backup."""
@@ -157,12 +284,13 @@ class Vault:
"""
num_changed = 0
for _note in self.notes:
for _note in self.notes_in_scope:
if _note.delete_inline_tag(tag):
log.trace(f"Deleted tag from {_note.note_path}")
num_changed += 1
if num_changed > 0:
self.metadata.delete(self.notes[0].inline_tags.metadata_key, tag)
self._rebuild_vault_metadata()
return num_changed
@@ -178,23 +306,107 @@ class Vault:
"""
num_changed = 0
for _note in self.notes:
for _note in self.notes_in_scope:
if _note.delete_metadata(key, value):
log.trace(f"Deleted metadata from {_note.note_path}")
num_changed += 1
if num_changed > 0:
self.metadata.delete(key, value)
self._rebuild_vault_metadata()
return num_changed
def export_metadata(self, path: str, export_format: str = "csv") -> None:
"""Write metadata to a csv file.
Args:
path (Path): Path to write csv file to.
export_format (str, optional): Export as 'csv' or 'json'. Defaults to "csv".
"""
export_file = Path(path).expanduser().resolve()
if not export_file.parent.exists():
alerts.error(f"Path does not exist: {export_file.parent}")
raise typer.Exit(code=1)
match export_format:
case "csv":
with export_file.open(mode="w", encoding="UTF8") as f:
writer = csv.writer(f)
writer.writerow(["Metadata Type", "Key", "Value"])
for key, value in self.metadata.frontmatter.items():
if isinstance(value, list):
if len(value) > 0:
for v in value:
writer.writerow(["frontmatter", key, v])
else:
writer.writerow(["frontmatter", key, v])
for key, value in self.metadata.inline_metadata.items():
if isinstance(value, list):
if len(value) > 0:
for v in value:
writer.writerow(["inline_metadata", key, v])
else:
writer.writerow(["frontmatter", key, v])
for tag in self.metadata.tags:
writer.writerow(["tags", "", f"{tag}"])
case "json":
dict_to_dump = {
"frontmatter": self.metadata.dict,
"inline_metadata": self.metadata.inline_metadata,
"tags": self.metadata.tags,
}
with export_file.open(mode="w", encoding="UTF8") as f:
json.dump(dict_to_dump, f, indent=4, ensure_ascii=False, sort_keys=True)
def export_notes_to_csv(self, path: str) -> None:
"""Export notes and their associated metadata to a csv file. This is useful as a template for importing metadata changes to a vault.
Args:
path (str): Path to write csv file to.
"""
export_file = Path(path).expanduser().resolve()
if not export_file.parent.exists():
alerts.error(f"Path does not exist: {export_file.parent}")
raise typer.Exit(code=1)
with export_file.open(mode="w", encoding="UTF8") as f:
writer = csv.writer(f)
writer.writerow(["path", "type", "key", "value"])
for _note in self.all_notes:
for key, value in _note.frontmatter.dict.items():
for v in value:
writer.writerow(
[_note.note_path.relative_to(self.vault_path), "frontmatter", key, v]
)
for key, value in _note.inline_metadata.dict.items():
for v in value:
writer.writerow(
[
_note.note_path.relative_to(self.vault_path),
"inline_metadata",
key,
v,
]
)
for tag in _note.inline_tags.list:
writer.writerow(
[_note.note_path.relative_to(self.vault_path), "tag", "", f"{tag}"]
)
def get_changed_notes(self) -> list[Note]:
"""Returns a list of notes that have changes.
"""Return a list of notes that have changes.
Returns:
list[Note]: List of notes that have changes.
"""
changed_notes = []
for _note in self.notes:
for _note in self.notes_in_scope:
if _note.has_changes():
changed_notes.append(_note)
@@ -203,66 +415,54 @@ 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("Active path filter", str(self.path_filter))
table.add_row("Notes with updates", str(len(self.get_changed_notes())))
table.add_row("Notes in scope", str(len(self.notes_in_scope)))
table.add_row("Notes excluded from scope", str(self.num_excluded_notes()))
table.add_row("Active filters", str(len(self.filters)))
table.add_row("Notes with changes", str(len(self.get_changed_notes())))
table.add_row("Insert Location", str(self.insert_location.value))
Console().print(table)
console.print(table)
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_in_scope, start=1):
table.add_row(str(_n), str(_note.note_path.relative_to(self.vault_path)))
console.print(table)
def num_excluded_notes(self) -> int:
"""Count number of excluded notes."""
excluded_notes = [
p.resolve()
for p in self.vault_path.glob("**/*")
if p.suffix in [".md", ".MD", ".markdown", ".MARKDOWN"] and p not in self.note_paths
]
return len(excluded_notes)
def num_notes(self) -> int:
"""Number of notes in the vault.
Returns:
int: Number of notes in the vault.
"""
return len(self.notes)
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.
def move_inline_metadata(self, location: InsertLocation) -> int:
"""Move all inline metadata to the selected location.
Args:
key (str): Key to rename.
value_1 (str): Value to rename or new name of key if no value_2 is provided.
value_2 (str, optional): New value.
location (InsertLocation): Location to move inline metadata to.
Returns:
int: Number of notes that had metadata renamed.
int: Number of notes that had inline metadata moved.
"""
num_changed = 0
for _note in self.notes:
if _note.rename_metadata(key, value_1, value_2):
for _note in self.notes_in_scope:
if _note.write_delete_inline_metadata():
log.trace(f"Deleted inline metadata from {_note.note_path}")
num_changed += 1
_note.write_all_inline_metadata(location)
log.trace(f"Wrote all inline metadata to {_note.note_path}")
if num_changed > 0:
self.metadata.rename(key, value_1, value_2)
self._rebuild_vault_metadata()
return num_changed
def num_excluded_notes(self) -> int:
"""Count number of excluded notes."""
return len(self.all_notes) - len(self.notes_in_scope)
def rename_inline_tag(self, old_tag: str, new_tag: str) -> int:
"""Rename an inline tag in the vault.
@@ -275,19 +475,128 @@ class Vault:
"""
num_changed = 0
for _note in self.notes:
for _note in self.notes_in_scope:
if _note.rename_inline_tag(old_tag, new_tag):
log.trace(f"Renamed inline tag in {_note.note_path}")
num_changed += 1
if num_changed > 0:
self.metadata.rename(self.notes[0].inline_tags.metadata_key, old_tag, new_tag)
self._rebuild_vault_metadata()
return num_changed
def write(self) -> None:
"""Write changes to the vault."""
log.debug("Writing changes to vault...")
if self.dry_run is False:
for _note in self.notes:
log.trace(f"writing to {_note.note_path}")
_note.write()
def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> int:
"""Rename a key or key-value pair in the note's metadata.
If no value is provided, will rename an entire key.
Args:
key (str): Key to rename.
value_1 (str): Value to rename or new name of key if no value_2 is provided.
value_2 (str, optional): New value.
Returns:
int: Number of notes that had metadata renamed.
"""
num_changed = 0
for _note in self.notes_in_scope:
if _note.rename_metadata(key, value_1, value_2):
log.trace(f"Renamed metadata in {_note.note_path}")
num_changed += 1
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed
def transpose_metadata(
self,
begin: MetadataType,
end: MetadataType,
key: str = None,
value: str | list[str] = None,
location: InsertLocation = None,
) -> int:
"""Transpose metadata from one type to another.
Args:
begin (MetadataType): Metadata type to transpose from.
end (MetadataType): Metadata type to transpose to.
key (str, optional): Key to transpose. Defaults to None.
value (str, optional): Value to transpose. Defaults to None.
location (InsertLocation, optional): Location to insert metadata. (Defaults to `vault.config.insert_location`)
Returns:
int: Number of notes that had metadata transposed.
"""
if location is None:
location = self.insert_location
num_changed = 0
for _note in self.notes_in_scope:
if _note.transpose_metadata(
begin=begin,
end=end,
key=key,
value=value,
location=location,
):
num_changed += 1
log.trace(f"Transposed metadata in {_note.note_path}")
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed
def update_from_dict(self, dictionary: dict[str, Any]) -> int:
"""Update note metadata from a dictionary. This is a destructive operation. All metadata in the specified notes not in the dictionary will be removed.
Requires a dictionary with the note path as the key and a dictionary of metadata as the value. Each key must have a list of associated dictionaries in the following format:
{
'type': 'frontmatter|inline_metadata|tag',
'key': 'string',
'value': 'string'
}
Args:
dictionary (dict[str, Any]): Dictionary to update metadata from.
Returns:
int: Number of notes that had metadata updated.
"""
num_changed = 0
for _note in self.all_notes:
path = _note.note_path.relative_to(self.vault_path)
if str(path) in dictionary:
log.info(f"Updating metadata for '{path}'")
num_changed += 1
_note.delete_all_metadata()
for row in dictionary[str(path)]:
if row["type"].lower() == "frontmatter":
_note.add_metadata(
area=MetadataType.FRONTMATTER, key=row["key"], value=row["value"]
)
if row["type"].lower() == "inline_metadata":
_note.add_metadata(
area=MetadataType.INLINE,
key=row["key"],
value=row["value"],
location=self.insert_location,
)
if row["type"].lower() == "tag" or row["type"].lower() == "tags":
_note.add_metadata(
area=MetadataType.TAGS,
value=row["value"],
location=self.insert_location,
)
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed

View File

@@ -44,6 +44,37 @@ def test_notice(capsys):
assert captured.out == "NOTICE | This prints in notice\n"
def test_alerts_debug(capsys):
"""Test debug."""
alerts.debug("This prints in debug")
captured = capsys.readouterr()
assert captured.out == "DEBUG | This prints in debug\n"
def test_usage(capsys):
"""Test usage."""
alerts.usage("This prints in usage")
captured = capsys.readouterr()
assert captured.out == "USAGE | This prints in usage\n"
alerts.usage(
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
width=80,
)
captured = capsys.readouterr()
assert "USAGE | Lorem ipsum dolor sit amet" in captured.out
assert " | incididunt ut labore et dolore magna aliqua" in captured.out
alerts.usage(
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
width=20,
)
captured = capsys.readouterr()
assert "USAGE | Lorem ipsum dolor" in captured.out
assert " | sit amet," in captured.out
assert " | adipisicing elit," in captured.out
def test_info(capsys):
"""Test info."""
alerts.info("This prints in info")
@@ -76,7 +107,7 @@ def test_logging(capsys, tmp_path, verbosity, log_to_file) -> None:
if verbosity >= 3:
assert logging.is_trace() is True
captured = capsys.readouterr()
assert captured.out == ""
assert not captured.out
assert logging.is_trace("trace text") is True
captured = capsys.readouterr()
@@ -97,7 +128,7 @@ def test_logging(capsys, tmp_path, verbosity, log_to_file) -> None:
if verbosity >= 2:
assert logging.is_debug() is True
captured = capsys.readouterr()
assert captured.out == ""
assert not captured.out
assert logging.is_debug("debug text") is True
captured = capsys.readouterr()
@@ -118,7 +149,7 @@ def test_logging(capsys, tmp_path, verbosity, log_to_file) -> None:
if verbosity >= 1:
assert logging.is_info() is True
captured = capsys.readouterr()
assert captured.out == ""
assert not captured.out
assert logging.is_info("info text") is True
captured = capsys.readouterr()
@@ -134,11 +165,11 @@ def test_logging(capsys, tmp_path, verbosity, log_to_file) -> None:
log.info("This is Info logging")
captured = capsys.readouterr()
assert captured.out == ""
assert not captured.out
assert logging.is_default() is True
captured = capsys.readouterr()
assert captured.out == ""
assert not captured.out
assert logging.is_default("default text") is True
captured = capsys.readouterr()

View File

@@ -3,412 +3,678 @@
How mocking works in this test suite:
1. The main_app() 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.
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 tests.helpers import Regex
from obsidian_metadata.models.enums import MetadataType
from tests.helpers import Regex, remove_ansi
def test_instantiate_application(test_application) -> None:
"""Test application."""
"""Test application.
GIVEN an application
WHEN the application is instantiated
THEN check the attributes are set correctly
"""
app = test_application
app.load_vault()
app._load_vault()
assert app.dry_run is False
assert app.config.name == "command_line_vault"
assert app.config.exclude_paths == [".git", ".obsidian"]
assert app.dry_run is False
assert app.vault.num_notes() == 13
assert len(app.vault.all_notes) == 13
def test_abort(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
"""Test aborting the application.
GIVEN an application
WHEN the users selects "abort" from the main menu
THEN check the application exits
"""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
"obsidian_metadata.models.application.Questions.ask_application_main",
return_value="abort",
)
app.main_app()
captured = capsys.readouterr()
assert "Vault Info" in captured.out
assert "Done!" in captured.out
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert "Done!" in captured
def test_list_notes(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
def test_add_metadata_frontmatter(test_application, mocker, capsys) -> None:
"""Test adding new metadata to the vault.
GIVEN an application
WHEN the wants to update a key in the frontmatter
THEN check the application updates the key
"""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["list_notes", KeyError],
"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.main_app()
captured = capsys.readouterr()
assert "04 no metadata/no_metadata_1.md" in captured.out
assert "02 inline/inline 2.md" in captured.out
assert "+inbox/Untitled.md" in captured.out
assert "00 meta/templates/data sample.md" in captured.out
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL)
def test_all_metadata(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
def test_add_metadata_inline(test_application, mocker, capsys) -> None:
"""Test adding new metadata to the vault.
GIVEN an application
WHEN the user wants to add a key in the inline metadata
THEN check the application updates the key
"""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["all_metadata", KeyError],
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["add_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_area",
return_value=MetadataType.INLINE,
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_new_key",
return_value="new_key",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_new_value",
return_value="new_key_value",
)
with pytest.raises(KeyError):
app.main_app()
captured = capsys.readouterr()
expected = re.escape("┃ Keys ┃ Values")
assert captured.out == Regex(expected)
expected = re.escape("Inline Tags │ breakfast")
assert captured.out == Regex(expected)
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL)
def test_add_metadata_tag(test_application, mocker, capsys) -> None:
"""Test adding new metadata to the vault.
GIVEN an application
WHEN the user wants to add a tag
THEN check the application adds the tag
"""
app = test_application
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["add_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_area",
return_value=MetadataType.TAGS,
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_new_tag",
return_value="new_tag",
)
with pytest.raises(KeyError):
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL)
def test_delete_inline_tag_1(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag.
GIVEN an application
WHEN the user wants to delete an inline tag
THEN check the application deletes the 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="breakfast",
)
with pytest.raises(KeyError):
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS +\| Deleted inline tag: breakfast in \d+ notes", re.DOTALL)
def test_delete_inline_tag_2(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag.
GIVEN an application
WHEN the user wants to delete an inline tag that does not exist
THEN check the application does not update any notes
"""
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 = remove_ansi(capsys.readouterr().out)
assert "WARNING | No notes were changed" in captured
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 = remove_ansi(capsys.readouterr().out)
assert r"WARNING | No notes found with a key matching: \d{7}" in captured
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 = remove_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS \| Deleted keys matching: d\\w\+ from \d+ notes", 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 = remove_ansi(capsys.readouterr().out)
assert r"WARNING | No notes found matching: area: \d{7}" in captured
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 = remove_ansi(capsys.readouterr().out)
assert r"SUCCESS | Deleted value ^front\w+$ from key area in 4 notes" in captured
def test_filter_notes(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["filter_notes", "list_notes", KeyError],
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["filter_notes", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_filter_path",
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["apply_path_filter", "list_notes", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_filter_path",
return_value="inline",
)
with pytest.raises(KeyError):
app.main_app()
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS +\| Loaded \d+ notes from \d+ total", re.DOTALL)
assert "02 inline/inline 2.md" in captured
assert "03 mixed/mixed 1.md" not in captured
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_metadata_filter", "list_notes", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_key",
return_value="on_one_note",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_value",
return_value="",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert "04 no metadata/no_metadata_1.md" not in captured.out
assert "02 inline/inline 1.md" in captured.out
assert captured.out == Regex(r"SUCCESS +\| Loaded.*1.*notes from.*\d+.*total", re.DOTALL)
assert "02 inline/inline 2.md" in captured.out
assert "+inbox/Untitled.md" not in captured.out
assert "00 meta/templates/data sample.md" not in captured.out
assert "03 mixed/mixed 1.md" not in captured.out
def test_rename_key_success(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
def test_filter_clear(test_application, mocker, capsys) -> None:
"""Test clearing filters."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["rename_key", KeyError],
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["filter_notes", "filter_notes", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_key",
return_value="tags",
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["apply_metadata_filter", "list_filters", "list_notes", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_new_key",
return_value="new_tags",
"obsidian_metadata.models.application.Questions.ask_existing_key",
return_value="on_one_note",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_value",
return_value="",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_number",
return_value="1",
)
with pytest.raises(KeyError):
app.main_app()
captured = capsys.readouterr()
assert captured.out == Regex(r"Renamed.*tags.*to.*new_tags.*in.*\d+.*notes", re.DOTALL)
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert "02 inline/inline 2.md" in captured
assert "03 mixed/mixed 1.md" in captured
assert "01 frontmatter/frontmatter 4.md" in captured
assert "04 no metadata/no_metadata_1.md " in captured
def test_rename_key_fail(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
def test_inspect_metadata_all(test_application, mocker, capsys) -> None:
"""Test backing up a vault."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["rename_key", KeyError],
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["inspect_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_key",
return_value="tag",
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["all_metadata", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_new_key",
return_value="new_tags",
)
with pytest.raises(KeyError):
app.main_app()
captured = capsys.readouterr()
assert "WARNING | No notes were changed" in captured.out
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert captured == Regex(r"type +│ article", re.DOTALL)
def test_rename_inline_tag_success(test_application, mocker, capsys) -> None:
def test_rename_inline_tag(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["rename_inline_tag", KeyError],
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_inline_tag",
return_value="breakfast",
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["rename_inline_tag", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_new_tag",
return_value="new_tag",
)
with pytest.raises(KeyError):
app.main_app()
captured = capsys.readouterr()
assert captured.out == Regex(r"Renamed.*breakfast.*to.*new_tag.*in.*\d+.*notes", re.DOTALL)
def test_rename_inline_tag_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_main_application",
side_effect=["rename_inline_tag", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_inline_tag",
"obsidian_metadata.models.application.Questions.ask_existing_inline_tag",
return_value="not_a_tag",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_new_tag",
"obsidian_metadata.models.application.Questions.ask_new_tag",
return_value="new_tag",
)
with pytest.raises(KeyError):
app.main_app()
captured = capsys.readouterr()
assert captured.out == Regex(r"WARNING +\| No notes were changed", re.DOTALL)
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert "No notes were changed" in captured
def test_delete_inline_tag_success(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["delete_inline_tag", KeyError],
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_inline_tag",
"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.main_app()
captured = capsys.readouterr()
assert captured.out == Regex(r"SUCCESS +\| Deleted.*\d+.*notes", re.DOTALL)
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert captured == Regex(r"Renamed breakfast to new_tag in \d+ notes", re.DOTALL)
def test_delete_inline_tag_fail(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
def test_rename_key(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["delete_inline_tag", KeyError],
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_inline_tag",
return_value="not_a_tag_in_vault",
"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.main_app()
captured = capsys.readouterr()
assert captured.out == Regex(r"WARNING +\| No notes were changed", re.DOTALL)
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert "WARNING | No notes were changed" in captured
def test_delete_key_success(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["delete_key", KeyError],
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_keys_regex",
return_value=r"d\w+",
"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.main_app()
captured = capsys.readouterr()
assert captured.out == Regex(
r"SUCCESS +\|.*Deleted.*keys.*matching:.*d\\w\+.*from.*10", re.DOTALL
)
def test_delete_key_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_main_application",
side_effect=["delete_key", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_keys_regex",
return_value=r"\d{7}",
)
with pytest.raises(KeyError):
app.main_app()
captured = capsys.readouterr()
assert captured.out == Regex(r"WARNING +\| No notes found with a.*key.*matching", re.DOTALL)
def test_rename_value_success(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["rename_value", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_key",
return_value="area",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_value",
return_value="frontmatter",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_new_value",
return_value="new_key",
)
with pytest.raises(KeyError):
app.main_app()
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)
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert captured == 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()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["rename_value", KeyError],
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_key",
"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_for_existing_value",
"obsidian_metadata.models.application.Questions.ask_existing_value",
return_value="not_exists",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_new_value",
"obsidian_metadata.models.application.Questions.ask_new_value",
return_value="new_key",
)
with pytest.raises(KeyError):
app.main_app()
captured = capsys.readouterr()
assert captured.out == Regex(r"WARNING +\| No notes were changed", re.DOTALL)
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert "WARNING | No notes were changed" in captured
def test_delete_value_success(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["delete_value", KeyError],
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_key",
"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_for_existing_value_regex",
return_value=r"^front\w+$",
"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.main_app()
captured = capsys.readouterr()
assert captured.out == Regex(
r"SUCCESS +\| Deleted value.*\^front\\w\+\$.*from.*key.*area.*in.*\d+.*notes", re.DOTALL
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert captured == Regex(
r"SUCCESS +\| Renamed 'area:frontmatter' to 'area:new_key' in \d+ notes", re.DOTALL
)
def test_delete_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_main_application",
side_effect=["delete_value", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_key",
return_value="area",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_value_regex",
return_value=r"\d{7}",
)
with pytest.raises(KeyError):
app.main_app()
captured = capsys.readouterr()
assert captured.out == Regex(r"WARNING +\| No notes found matching:", 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()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["review_changes", KeyError],
)
with pytest.raises(KeyError):
app.main_app()
captured = capsys.readouterr()
assert captured.out == Regex(r"INFO +\| No changes to review", re.DOTALL)
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert "INFO | No changes to review" in captured
def test_review_changes(test_application, mocker, capsys) -> None:
"""Review changes when no changes to vault."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_main_application",
side_effect=["delete_key", "review_changes", KeyError],
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", "review_changes", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_existing_keys_regex",
return_value=r"d\w+",
"obsidian_metadata.models.application.Questions.ask_existing_key",
return_value="tags",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_confirm",
return_value=True,
"obsidian_metadata.models.application.Questions.ask_new_key",
return_value="new_tags",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_for_selection",
side_effect=[1, "return"],
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["rename_key", 1, "return"],
)
with pytest.raises(KeyError):
app.main_app()
captured = capsys.readouterr()
assert captured.out == Regex(r".*Found.*\d+.*changed notes in the vault.*", re.DOTALL)
assert "- date_created: 2022-12-22" in captured.out
assert "+ - breakfast" in captured.out
app.application_main()
captured = remove_ansi(capsys.readouterr().out)
assert captured == Regex(r".*Found \d+ changed notes in the vault", re.DOTALL)
assert "- tags:" in captured
assert "+ new_tags:" in captured
def test_transpose_metadata_1(test_application, mocker, capsys) -> None:
"""Transpose metadata.
GIVEN a test application
WHEN the user wants to transpose all inline metadata to frontmatter
THEN the metadata is transposed
"""
app = test_application
app._load_vault()
assert app.vault.metadata.inline_metadata["inline_key"] == ["inline_key_value"]
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["reorganize_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["inline_to_frontmatter", "transpose_all"],
)
with pytest.raises(KeyError):
app.application_main()
assert app.vault.metadata.inline_metadata == {}
assert app.vault.metadata.frontmatter["inline_key"] == ["inline_key_value"]
captured = remove_ansi(capsys.readouterr().out)
assert "SUCCESS | Transposed Inline Metadata to Frontmatter in 5 notes" in captured
def test_transpose_metadata_2(test_application, mocker) -> None:
"""Transpose metadata.
GIVEN a test application
WHEN the user wants to transpose all frontmatter to inline metadata
THEN the metadata is transposed
"""
app = test_application
app._load_vault()
assert app.vault.metadata.frontmatter["date_created"] == ["2022-12-21", "2022-12-22"]
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["reorganize_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["frontmatter_to_inline", "transpose_all"],
)
with pytest.raises(KeyError):
app.application_main()
assert app.vault.metadata.inline_metadata["date_created"] == ["2022-12-21", "2022-12-22"]
assert app.vault.metadata.frontmatter == {}
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 = remove_ansi(capsys.readouterr().out)
assert captured == Regex(
r"SUCCESS +\| Vault backed up to:[-\w\d\/\s]+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 = remove_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS +\| Backup deleted", re.DOTALL)

View File

@@ -1,6 +1,9 @@
# type: ignore
"""Test obsidian-metadata CLI."""
import shutil
from pathlib import Path
from typer.testing import CliRunner
from obsidian_metadata.cli import app
@@ -17,13 +20,20 @@ def test_version() -> None:
assert result.output == Regex(r"obsidian_metadata: v\d+\.\d+\.\d+$")
def test_application(test_vault, tmp_path) -> None:
def test_application(tmp_path) -> None:
"""Test the application."""
vault_path = test_vault
source_dir = Path(__file__).parent / "fixtures" / "test_vault"
dest_dir = Path(tmp_path / "vault")
if not source_dir.exists():
raise FileNotFoundError(f"Sample vault not found: {source_dir}")
shutil.copytree(source_dir, dest_dir)
config_path = tmp_path / "config.toml"
result = runner.invoke(
app,
["--vault-path", vault_path, "--config-file", config_path],
["--vault-path", dest_dir, "--config-file", config_path],
# input=KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.ENTER, # noqa: ERA001
)

View File

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

View File

@@ -9,6 +9,13 @@ import pytest
from obsidian_metadata._config import Config
from obsidian_metadata.models.application import Application
CONFIG_1 = """
["Test Vault"]
exclude_paths = [".git", ".obsidian", "ignore_folder"]
insert_location = "TOP"
path = "TMPDIR_VAULT_PATH"
"""
def remove_all(root: Path):
"""Remove all files and directories in a directory."""
@@ -37,6 +44,33 @@ def sample_note(tmp_path) -> Path:
dest_file.unlink()
@pytest.fixture()
def short_notes(tmp_path) -> Path:
"""Fixture which creates two temporary note files.
Yields:
Tuple[Path, Path]: Tuple of two temporary note files.
1. Very short note with frontmatter
2. Very short note without any frontmatter
"""
source_file1: Path = Path("tests/fixtures/short_textfile.md")
source_file2: Path = Path("tests/fixtures/no_metadata.md")
if not source_file1.exists():
raise FileNotFoundError(f"Original file not found: {source_file1}")
if not source_file2.exists():
raise FileNotFoundError(f"Original file not found: {source_file2}")
dest_file1: Path = Path(tmp_path / source_file1.name)
dest_file2: Path = Path(tmp_path / source_file2.name)
shutil.copy(source_file1, dest_file1)
shutil.copy(source_file2, dest_file2)
yield dest_file1, dest_file2
# after test - remove fixtures
dest_file1.unlink()
dest_file2.unlink()
@pytest.fixture()
def sample_vault(tmp_path) -> Path:
"""Fixture which creates a sample vault."""
@@ -68,10 +102,16 @@ def test_vault(tmp_path) -> Path:
raise FileNotFoundError(f"Sample vault not found: {source_dir}")
shutil.copytree(source_dir, dest_dir)
yield dest_dir
config_path = Path(tmp_path / "config.toml")
config_path.write_text(CONFIG_1.replace("TMPDIR_VAULT_PATH", str(dest_dir)))
config = Config(config_path=config_path)
vault_config = config.vaults[0]
yield vault_config
# after test - remove fixtures
shutil.rmtree(dest_dir)
config_path.unlink()
if backup_dir.exists():
shutil.rmtree(backup_dir)

6
tests/fixtures/broken_frontmatter.md vendored Normal file
View File

@@ -0,0 +1,6 @@
---
tags:
invalid = = "content"
---
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est la

View File

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

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

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

View File

@@ -3,25 +3,16 @@ area: frontmatter
date_created: 2022-12-22
date_modified: 2022-12-22
tags:
- food/fruit/apple
- food/fruit/pear
- dinner
- lunch
- breakfast
thoughts:
rating: 8
reviewable: false
levels:
level1:
- level1a
- level1b
level2:
- level2a
- level2b
- food/fruit/apple
- food/fruit/pear
- dinner
- lunch
- breakfast
author: John Doe
status: new
type: ["book", "article", "note", "one-off"]
---
# Page Title H1
# Headings

View File

@@ -3,25 +3,16 @@ area: frontmatter
date_created: 2022-12-22
date_modified: 2022-11-14
tags:
- food/fruit/apple
- food/fruit/pear
- dinner
- lunch
- breakfast
thoughts:
rating: 8
reviewable: false
levels:
level1:
- level1a
- level1b
level2:
- level2a
- level2b
- food/fruit/apple
- food/fruit/pear
- dinner
- lunch
- breakfast
author: John Doe
status: new
type: ["book", "article", "note"]
---
# Page Title H1
# Headings

View File

@@ -3,25 +3,16 @@ area: frontmatter
date_created: 2022-12-22
date_modified: 2022-10-01
tags:
- food/fruit/apple
- food/fruit/pear
- dinner
- lunch
- breakfast
thoughts:
rating: 8
reviewable: false
levels:
level1:
- level1a
- level1b
level2:
- level2a
- level2b
- food/fruit/apple
- food/fruit/pear
- dinner
- lunch
- breakfast
author: John Doe
status: new
type: ["book", "article", "note"]
---
# Page Title H1
# Headings

View File

@@ -3,21 +3,11 @@ area: frontmatter
date_created: 2022-12-22
date_modified: 2022-12-22
tags:
- food/fruit/apple
- food/fruit/pear
- dinner
- lunch
- breakfast
thoughts:
rating: 8
reviewable: false
levels:
level1:
- level1a
- level1b
level2:
- level2a
- level2b
- food/fruit/apple
- food/fruit/pear
- dinner
- lunch
- breakfast
author: John Doe
status: new
type: ["book", "article", "note"]

View File

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

View File

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

View File

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

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

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

View File

@@ -1,14 +1,15 @@
---
date_created: 2022-12-22
tags:
- shared_tag
- frontmatter_tag1
- frontmatter_tag2
-
- 📅/frontmatter_tag3
- shared_tag
- frontmatter_tag1
- frontmatter_tag2
- 📅/frontmatter_tag3
frontmatter_Key1: author name
frontmatter_Key2: ["article", "note"]
shared_key1: shared_key1_value
shared_key1:
- shared_key1_value
- shared_key1_value3
shared_key2: shared_key2_value1
---
@@ -18,10 +19,12 @@ top_key1:: top_key1_value
**top_key2:: top_key2_value**
top_key3:: [[top_key3_value_as_link]]
shared_key1:: shared_key1_value
shared_key1:: shared_key1_value2
shared_key2:: shared_key2_value2
emoji_📅_key:: emoji_📅_key_value
key📅:: 📅_key_value
# Heading 1
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. #intext_tag1 Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu [intext_key:: intext_value] fugiat nulla (#intext_tag2) pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est lab
```python

View File

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

View File

@@ -22,6 +22,19 @@ class KeyInputs:
THREE = "3"
def remove_ansi(text) -> str:
"""Remove ANSI escape sequences from a string.
Args:
text (str): String to remove ANSI escape sequences from.
Returns:
str: String without ANSI escape sequences.
"""
ansi_chars = re.compile(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]")
return ansi_chars.sub("", text)
class Regex:
"""Assert that a given string meets some expectations.

View File

@@ -0,0 +1,530 @@
# type: ignore
"""Test the Frontmatter object from metadata.py."""
import pytest
from obsidian_metadata.models.metadata import Frontmatter
FRONTMATTER_CONTENT: str = """
---
tags:
- tag_1
- tag_2
-
- 📅/tag_3
frontmatter_Key1: "frontmatter_Key1_value"
frontmatter_Key2: ["note", "article"]
shared_key1: "shared_key1_value"
---
more content
---
horizontal: rule
---
"""
INLINE_CONTENT = """\
repeated_key:: repeated_key_value1
#inline_tag_top1,#inline_tag_top2
**bold_key1**:: bold_key1_value
**bold_key2:: bold_key2_value**
link_key:: [[link_key_value]]
tag_key:: #tag_key_value
emoji_📅_key:: emoji_📅_key_value
**#bold_tag**
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. [in_text_key1:: in_text_key1_value] Ut enim ad minim veniam, quis nostrud exercitation [in_text_key2:: in_text_key2_value] ullamco laboris nisi ut aliquip ex ea commodo consequat. #in_text_tag Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
```python
#ffffff
# This is sample text [no_key:: value]with tags and metadata
#in_codeblock_tag1
#ffffff;
in_codeblock_key:: in_codeblock_value
The quick brown fox jumped over the #in_codeblock_tag2
```
repeated_key:: repeated_key_value2
"""
def test_frontmatter_create_1() -> None:
"""Test frontmatter creation.
GIVEN valid frontmatter content
WHEN a Frontmatter object is created
THEN parse the YAML frontmatter and add it to the object
"""
frontmatter = Frontmatter(INLINE_CONTENT)
assert frontmatter.dict == {}
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.dict == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.dict_original == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
def test_frontmatter_create_2() -> None:
"""Test frontmatter creation error.
GIVEN invalid frontmatter content
WHEN a Frontmatter object is created
THEN raise ValueError
"""
fn = """---
tags: tag
invalid = = "content"
---
"""
with pytest.raises(AttributeError):
Frontmatter(fn)
def test_frontmatter_create_3():
"""Test frontmatter creation error.
GIVEN empty frontmatter content
WHEN a Frontmatter object is created
THEN set the dict to an empty dict
"""
content = "---\n\n---"
frontmatter = Frontmatter(content)
assert frontmatter.dict == {}
def test_frontmatter_create_4():
"""Test frontmatter creation error.
GIVEN empty frontmatter content with a yaml marker
WHEN a Frontmatter object is created
THEN set the dict to an empty dict
"""
content = "---\n-\n---"
frontmatter = Frontmatter(content)
assert frontmatter.dict == {}
def test_frontmatter_add_1():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with an existing key
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("frontmatter_Key1") is False
def test_frontmatter_add_2():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with an existing key and existing value
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("frontmatter_Key1", "frontmatter_Key1_value") is False
def test_frontmatter_add_3():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with a new key
THEN return True and add the key to the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("added_key") is True
assert "added_key" in frontmatter.dict
def test_frontmatter_add_4():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with a new key and a new value
THEN return True and add the key and the value to the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("added_key", "added_value") is True
assert frontmatter.dict["added_key"] == ["added_value"]
def test_frontmatter_add_5():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with an existing key and a new value
THEN return True and add the value to the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("frontmatter_Key1", "new_value") is True
assert frontmatter.dict["frontmatter_Key1"] == ["frontmatter_Key1_value", "new_value"]
def test_frontmatter_add_6():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with an existing key and a list of new values
THEN return True and add the values to the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("frontmatter_Key1", ["new_value", "new_value2"]) is True
assert frontmatter.dict["frontmatter_Key1"] == [
"frontmatter_Key1_value",
"new_value",
"new_value2",
]
def test_frontmatter_add_7():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with an existing key and a list of values including an existing value
THEN return True and add the new values to the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert (
frontmatter.add("frontmatter_Key1", ["frontmatter_Key1_value", "new_value", "new_value2"])
is True
)
assert frontmatter.dict["frontmatter_Key1"] == [
"frontmatter_Key1_value",
"new_value",
"new_value2",
]
def test_frontmatter_contains_1():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key
THEN return True if the key is found
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("frontmatter_Key1") is True
def test_frontmatter_contains_2():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key
THEN return False if the key is not found
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("no_key") is False
def test_frontmatter_contains_3():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key and a value
THEN return True if the key and value is found
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("frontmatter_Key2", "article") is True
def test_frontmatter_contains_4():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key and a value
THEN return False if the key and value is not found
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("frontmatter_Key2", "no value") is False
def test_frontmatter_contains_5():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key regex
THEN return True if a key matches the regex
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains(r"\d$", is_regex=True) is True
def test_frontmatter_contains_6():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key regex
THEN return False if no key matches the regex
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains(r"^\d", is_regex=True) is False
def test_frontmatter_contains_7():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key and value regex
THEN return True if a value matches the regex
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("key", r"\w\d_", is_regex=True) is True
def test_frontmatter_contains_8():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key and value regex
THEN return False if a value does not match the regex
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("key", r"_\d", is_regex=True) is False
def test_frontmatter_delete_1():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with a key that does not exist
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete("no key") is False
def test_frontmatter_delete_2():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with an existing key and a value that does not exist
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete("tags", "no value") is False
def test_frontmatter_delete_3():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with a regex that does not match any keys
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete(r"\d{3}") is False
def test_frontmatter_delete_4():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with an existing key and a regex that does not match any values
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete("tags", r"\d{5}") is False
def test_frontmatter_delete_5():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with an existing key and an existing value
THEN return True and delete the value from the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete("tags", "tag_2") is True
assert "tag_2" not in frontmatter.dict["tags"]
assert "tags" in frontmatter.dict
def test_frontmatter_delete_6():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with an existing key
THEN return True and delete the key from the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete("tags") is True
assert "tags" not in frontmatter.dict
def test_frontmatter_delete_7():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with a regex that matches a key
THEN return True and delete the matching keys from the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete(r"front\w+") is True
assert "frontmatter_Key1" not in frontmatter.dict
assert "frontmatter_Key2" not in frontmatter.dict
def test_frontmatter_delete_8():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with an existing key and a regex that matches values
THEN return True and delete the matching values
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete("tags", r"\w+_[23]") is True
assert "tag_2" not in frontmatter.dict["tags"]
assert "📅/tag_3" not in frontmatter.dict["tags"]
assert "tag_1" in frontmatter.dict["tags"]
def test_frontmatter_delete_all():
"""Test Frontmatter delete_all method.
GIVEN Frontmatter with multiple keys
WHEN delete_all is called
THEN all keys and values are deleted
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
frontmatter.delete_all()
assert frontmatter.dict == {}
def test_frontmatter_has_changes_1():
"""Test frontmatter has_changes() method.
GIVEN a Frontmatter object
WHEN no changes have been made to the object
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.has_changes() is False
def test_frontmatter_has_changes_2():
"""Test frontmatter has_changes() method.
GIVEN a Frontmatter object
WHEN changes have been made to the object
THEN return True
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
frontmatter.dict["new key"] = ["new value"]
assert frontmatter.has_changes() is True
def test_frontmatter_rename_1():
"""Test frontmatter rename() method.
GIVEN a Frontmatter object
WHEN the rename() method is called with a key
THEN return False if the key is not found
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.rename("no key", "new key") is False
def test_frontmatter_rename_2():
"""Test frontmatter rename() method.
GIVEN a Frontmatter object
WHEN the rename() method is called with an existing key and non-existing value
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.rename("tags", "no tag", "new key") is False
def test_frontmatter_rename_3():
"""Test frontmatter rename() method.
GIVEN a Frontmatter object
WHEN the rename() method is called with an existing key
THEN return True and rename the key
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.rename("frontmatter_Key1", "new key") is True
assert "frontmatter_Key1" not in frontmatter.dict
assert frontmatter.dict["new key"] == ["frontmatter_Key1_value"]
def test_frontmatter_rename_4():
"""Test frontmatter rename() method.
GIVEN a Frontmatter object
WHEN the rename() method is called with an existing key and value
THEN return True and rename the value
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.rename("tags", "tag_2", "new tag") is True
assert "tag_2" not in frontmatter.dict["tags"]
assert "new tag" in frontmatter.dict["tags"]
def test_frontmatter_rename_5():
"""Test frontmatter rename() method.
GIVEN a Frontmatter object
WHEN the rename() method is called with an existing key and value and the new value already exists
THEN return True and remove the old value leaving one instance of the new value
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.rename("tags", "tag_1", "tag_2") is True
assert "tag_1" not in frontmatter.dict["tags"]
assert frontmatter.dict["tags"] == ["tag_2", "📅/tag_3"]
def test_frontmatter_to_yaml_1():
"""Test Frontmatter to_yaml method.
GIVEN a dictionary
WHEN the to_yaml method is called
THEN return a string with the yaml representation of the dictionary
"""
new_frontmatter: str = """\
tags:
- tag_1
- tag_2
- 📅/tag_3
frontmatter_Key1: frontmatter_Key1_value
frontmatter_Key2:
- article
- note
shared_key1: shared_key1_value
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.to_yaml() == new_frontmatter
def test_frontmatter_to_yaml_2():
"""Test Frontmatter to_yaml method.
GIVEN a dictionary
WHEN the to_yaml method is called with sort_keys=True
THEN return a string with the sorted yaml representation of the dictionary
"""
new_frontmatter_sorted: str = """\
frontmatter_Key1: frontmatter_Key1_value
frontmatter_Key2:
- article
- note
shared_key1: shared_key1_value
tags:
- tag_1
- tag_2
- 📅/tag_3
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.to_yaml(sort_keys=True) == new_frontmatter_sorted

View File

@@ -0,0 +1,426 @@
# type: ignore
"""Test inline metadata from metadata.py."""
from obsidian_metadata.models.metadata import InlineMetadata
FRONTMATTER_CONTENT: str = """
---
tags:
- tag_1
- tag_2
-
- 📅/tag_3
frontmatter_Key1: "frontmatter_Key1_value"
frontmatter_Key2: ["note", "article"]
shared_key1: "shared_key1_value"
---
more content
---
horizontal: rule
---
"""
INLINE_CONTENT = """\
key1:: value1
key1:: value2
key1:: value3
key2:: value1
Paragraph of text with an [inline_key:: value1] and [inline_key:: value2] and [inline_key:: value3] which should do it.
> blockquote_key:: value1
> blockquote_key:: value2
- list_key:: value1
- list_key:: value2
1. list_key:: value1
2. list_key:: value2
"""
def test__grab_inline_metadata_1():
"""Test grab inline metadata.
GIVEN content that has no inline metadata
WHEN grab_inline_metadata is called
THEN an empty dict is returned
"""
content = """
---
frontmatter_key1: frontmatter_key1_value
---
not_a_key: not_a_value
```
key:: in_codeblock
```
"""
inline = InlineMetadata(content)
assert inline.dict == {}
def test__grab_inline_metadata_2():
"""Test grab inline metadata.
GIVEN content that has inline metadata
WHEN grab_inline_metadata is called
THEN the inline metadata is parsed and returned as a dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.dict == {
"blockquote_key": ["value1", "value2"],
"inline_key": ["value1", "value2", "value3"],
"key1": ["value1", "value2", "value3"],
"key2": ["value1"],
"list_key": ["value1", "value2", "value1", "value2"],
}
def test_inline_metadata_add_1():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with an existing key
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("key1") is False
def test_inline_metadata_add_2():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with an existing key and existing value
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("key1", "value1") is False
def test_inline_metadata_add_3():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with a new key
THEN return True and add the key to the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("added_key") is True
assert "added_key" in inline.dict
def test_inline_metadata_add_4():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with a new key and a new value
THEN return True and add the key and the value to the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("added_key", "added_value") is True
assert inline.dict["added_key"] == ["added_value"]
def test_inline_metadata_add_5():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with an existing key and a new value
THEN return True and add the value to the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("key1", "new_value") is True
assert inline.dict["key1"] == ["value1", "value2", "value3", "new_value"]
def test_inline_metadata_add_6():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with an existing key and a list of new values
THEN return True and add the values to the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("key2", ["new_value", "new_value2"]) is True
assert inline.dict["key2"] == ["new_value", "new_value2", "value1"]
def test_inline_metadata_add_7():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with an existing key and a list of values including an existing value
THEN return True and add the new values to the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("key1", ["value1", "new_value", "new_value2"]) is True
assert inline.dict["key1"] == ["new_value", "new_value2", "value1", "value2", "value3"]
def test_inline_metadata_contains_1():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key
THEN return True if the key is found
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("key1") is True
def test_inline_metadata_contains_2():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key
THEN return False if the key is not found
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("no_key") is False
def test_inline_metadata_contains_3():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key and a value
THEN return True if the key and value is found
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("key1", "value1") is True
def test_inline_metadata_contains_4():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key and a value
THEN return False if the key and value is not found
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("key1", "no value") is False
def test_inline_metadata_contains_5():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key regex
THEN return True if a key matches the regex
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains(r"\d$", is_regex=True) is True
def test_inline_metadata_contains_6():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key regex
THEN return False if no key matches the regex
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains(r"^\d", is_regex=True) is False
def test_inline_metadata_contains_7():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key and value regex
THEN return True if a value matches the regex
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains(r"key\d", r"\w\d", is_regex=True) is True
def test_inline_metadata_contains_8():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key and value regex
THEN return False if a value does not match the regex
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("key1", r"_\d", is_regex=True) is False
def test_inline_metadata_delete_1():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with a key that does not exist
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete("no key") is False
def test_inline_metadata_delete_2():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with an existing key and a value that does not exist
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete("key1", "no value") is False
def test_inline_metadata_delete_3():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with a regex that does not match any keys
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete(r"\d{3}") is False
def test_inline_metadata_delete_4():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with an existing key and a regex that does not match any values
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete("key1", r"\d{5}") is False
def test_inline_metadata_delete_5():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with an existing key and an existing value
THEN return True and delete the value from the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete("key1", "value1") is True
assert "value1" not in inline.dict["key1"]
assert "key1" in inline.dict
def test_inline_metadata_delete_6():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with an existing key
THEN return True and delete the key from the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete("key1") is True
assert "key1" not in inline.dict
def test_inline_metadata_delete_7():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with a regex that matches a key
THEN return True and delete the matching keys from the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete(r"key\w+") is True
assert "key1" not in inline.dict
assert "key2" not in inline.dict
def test_inline_metadata_delete_8():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with an existing key and a regex that matches values
THEN return True and delete the matching values
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete("key1", r"\w+\d") is True
assert "value1" not in inline.dict["key1"]
assert "value2" not in inline.dict["key1"]
assert "value3" not in inline.dict["key1"]
def test_inline_metadata_has_changes_1():
"""Test InlineMetadata has_changes() method.
GIVEN a InlineMetadata object
WHEN no changes have been made to the object
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.has_changes() is False
def test_inline_metadata_has_changes_2():
"""Test InlineMetadata has_changes() method.
GIVEN a InlineMetadata object
WHEN changes have been made to the object
THEN return True
"""
inline = InlineMetadata(INLINE_CONTENT)
inline.dict["new key"] = ["new value"]
assert inline.has_changes() is True
def test_inline_metadata_rename_1():
"""Test InlineMetadata rename() method.
GIVEN a InlineMetadata object
WHEN the rename() method is called with a key
THEN return False if the key is not found
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.rename("no key", "new key") is False
def test_inline_metadata_rename_2():
"""Test InlineMetadata rename() method.
GIVEN a InlineMetadata object
WHEN the rename() method is called with an existing key and non-existing value
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.rename("key1", "no value", "new value") is False
def test_inline_metadata_rename_3():
"""Test InlineMetadata rename() method.
GIVEN a InlineMetadata object
WHEN the rename() method is called with an existing key
THEN return True and rename the key
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.rename("key1", "new key") is True
assert "key1" not in inline.dict
assert inline.dict["new key"] == ["value1", "value2", "value3"]
def test_inline_metadata_rename_4():
"""Test InlineMetadata rename() method.
GIVEN a InlineMetadata object
WHEN the rename() method is called with an existing key and value
THEN return True and rename the value
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.rename("key1", "value1", "new value") is True
assert "value1" not in inline.dict["key1"]
assert "new value" in inline.dict["key1"]
def test_inline_metadata_rename_5():
"""Test InlineMetadata rename() method.
GIVEN a InlineMetadata object
WHEN the rename() method is called with an existing key and value and the new value already exists
THEN return True and remove the old value leaving one instance of the new value
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.rename("key1", "value1", "value2") is True
assert inline.dict["key1"] == ["value2", "value3"]

View File

@@ -2,15 +2,17 @@
"""Test metadata.py."""
from pathlib import Path
import pytest
from obsidian_metadata.models.enums import MetadataType
from obsidian_metadata.models.metadata import (
Frontmatter,
InlineMetadata,
InlineTags,
VaultMetadata,
)
from tests.helpers import Regex
from tests.helpers import Regex, remove_ansi
FILE_CONTENT: str = Path("tests/fixtures/test_vault/test1.md").read_text()
TAG_LIST: list[str] = ["tag 1", "tag 2", "tag 3"]
METADATA: dict[str, list[str]] = {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["note", "article"],
@@ -22,6 +24,7 @@ METADATA: dict[str, list[str]] = {
"top_key3": ["top_key3_value"],
"intext_key": ["intext_key_value"],
}
METADATA_2: dict[str, list[str]] = {"key1": ["value1"], "key2": ["value2", "value3"]}
FRONTMATTER_CONTENT: str = """
---
tags:
@@ -41,7 +44,6 @@ horizontal: rule
"""
INLINE_CONTENT = """\
repeated_key:: repeated_key_value1
#inline_tag_top1,#inline_tag_top2
**bold_key1**:: bold_key1_value
**bold_key2:: bold_key2_value**
@@ -64,348 +66,30 @@ repeated_key:: repeated_key_value2
"""
def test_vault_metadata(capsys) -> None:
"""Test VaultMetadata class."""
vm = VaultMetadata()
assert vm.dict == {}
def test_inline_tags_add() -> None:
"""Test inline tags add."""
tags = InlineTags(INLINE_CONTENT)
vm.add_metadata(METADATA)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
vm.print_keys()
captured = capsys.readouterr()
assert captured.out == Regex(r"frontmatter_Key1 +frontmatter_Key2 +intext_key")
vm.print_tags()
captured = capsys.readouterr()
assert captured.out == Regex(r"tag 1 +tag 2 +tag 3")
vm.print_metadata()
captured = capsys.readouterr()
assert captured.out == Regex(r"┃ Keys +┃ Values +┃")
assert captured.out == Regex(r"│ +│ tag 3 +│")
assert captured.out == Regex(r"│ frontmatter_Key1 +│ author name +│")
new_metadata = {"added_key": ["added_value"], "frontmatter_Key2": ["new_value"]}
vm.add_metadata(new_metadata)
assert vm.dict == {
"added_key": ["added_value"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "new_value", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert tags.add("bold_tag") is False
assert tags.add("new_tag") is True
assert tags.list == [
"bold_tag",
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"new_tag",
"tag_key_value",
]
def test_vault_metadata_contains() -> None:
"""Test contains method."""
vm = VaultMetadata()
vm.add_metadata(METADATA)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
def test_inline_tags_contains() -> None:
"""Test inline tags contains."""
tags = InlineTags(INLINE_CONTENT)
assert tags.contains("bold_tag") is True
assert tags.contains("no tag") is False
assert vm.contains("frontmatter_Key1") is True
assert vm.contains("frontmatter_Key2", "article") is True
assert vm.contains("frontmatter_Key3") is False
assert vm.contains("frontmatter_Key2", "no value") is False
assert vm.contains("1$", is_regex=True) is True
assert vm.contains("5$", is_regex=True) is False
assert vm.contains("tags", r"\d", is_regex=True) is True
assert vm.contains("tags", r"^\d", is_regex=True) is False
def test_vault_metadata_delete() -> None:
"""Test delete method."""
vm = VaultMetadata()
vm.add_metadata(METADATA)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.delete("no key") is False
assert vm.delete("tags", "no value") is False
assert vm.delete("tags", "tag 2") is True
assert vm.dict["tags"] == ["tag 1", "tag 3"]
assert vm.delete("tags") is True
assert "tags" not in vm.dict
def test_vault_metadata_rename() -> None:
"""Test rename method."""
vm = VaultMetadata()
vm.add_metadata(METADATA)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.rename("no key", "new key") is False
assert vm.rename("tags", "no tag", "new key") is False
assert vm.rename("tags", "tag 2", "new tag") is True
assert vm.dict["tags"] == ["new tag", "tag 1", "tag 3"]
assert vm.rename("tags", "old_tags") is True
assert vm.dict["old_tags"] == ["new tag", "tag 1", "tag 3"]
assert "tags" not in vm.dict
def test_frontmatter_create() -> None:
"""Test frontmatter creation."""
frontmatter = Frontmatter(INLINE_CONTENT)
assert frontmatter.dict == {}
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.dict == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.dict_original == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
def test_frontmatter_contains() -> None:
"""Test frontmatter contains."""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("frontmatter_Key1") is True
assert frontmatter.contains("frontmatter_Key2", "article") is True
assert frontmatter.contains("frontmatter_Key3") is False
assert frontmatter.contains("frontmatter_Key2", "no value") is False
assert frontmatter.contains(r"\d$", is_regex=True) is True
assert frontmatter.contains(r"^\d", is_regex=True) is False
assert frontmatter.contains("key", r"_\d", is_regex=True) is False
assert frontmatter.contains("key", r"\w\d_", is_regex=True) is True
def test_frontmatter_rename() -> None:
"""Test frontmatter rename."""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.dict == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.rename("no key", "new key") is False
assert frontmatter.rename("tags", "no tag", "new key") is False
assert frontmatter.has_changes() is False
assert frontmatter.rename("tags", "tag_2", "new tag") is True
assert frontmatter.dict["tags"] == ["new tag", "tag_1", "📅/tag_3"]
assert frontmatter.rename("tags", "old_tags") is True
assert frontmatter.dict["old_tags"] == ["new tag", "tag_1", "📅/tag_3"]
assert "tags" not in frontmatter.dict
assert frontmatter.has_changes() is True
def test_frontmatter_delete() -> None:
"""Test Frontmatter delete method."""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.dict == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.delete("no key") is False
assert frontmatter.delete("tags", "no value") is False
assert frontmatter.delete(r"\d{3}") is False
assert frontmatter.has_changes() is False
assert frontmatter.delete("tags", "tag_2") is True
assert frontmatter.dict["tags"] == ["tag_1", "📅/tag_3"]
assert frontmatter.delete("tags") is True
assert "tags" not in frontmatter.dict
assert frontmatter.has_changes() is True
assert frontmatter.delete("shared_key1", r"\w+") is True
assert frontmatter.dict["shared_key1"] == []
assert frontmatter.delete(r"\w.tter") is True
assert frontmatter.dict == {"shared_key1": []}
def test_frontmatter_yaml_conversion():
"""Test Frontmatter to_yaml method."""
new_frontmatter: str = """\
tags:
- tag_1
- tag_2
- 📅/tag_3
frontmatter_Key1: frontmatter_Key1_value
frontmatter_Key2:
- article
- note
shared_key1: shared_key1_value
"""
new_frontmatter_sorted: str = """\
frontmatter_Key1: frontmatter_Key1_value
frontmatter_Key2:
- article
- note
shared_key1: shared_key1_value
tags:
- tag_1
- tag_2
- 📅/tag_3
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.to_yaml() == new_frontmatter
assert frontmatter.to_yaml(sort_keys=True) == new_frontmatter_sorted
def test_inline_metadata_create() -> None:
"""Test inline metadata creation."""
inline = InlineMetadata(FRONTMATTER_CONTENT)
assert inline.dict == {}
inline = InlineMetadata(INLINE_CONTENT)
assert inline.dict == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.dict_original == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
def test_inline_contains() -> None:
"""Test inline metadata contains method."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("bold_key1") is True
assert inline.contains("bold_key2", "bold_key2_value") is True
assert inline.contains("bold_key3") is False
assert inline.contains("bold_key2", "no value") is False
assert inline.contains(r"\w{4}_key", is_regex=True) is True
assert inline.contains(r"^\d", is_regex=True) is False
assert inline.contains("1$", r"\d_value", is_regex=True) is True
assert inline.contains("key", r"^\d_value", is_regex=True) is False
def test_inline_metadata_rename() -> None:
"""Test inline metadata rename."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.dict == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.rename("no key", "new key") is False
assert inline.rename("repeated_key", "no value", "new key") is False
assert inline.has_changes() is False
assert inline.rename("repeated_key", "repeated_key_value1", "new value") is True
assert inline.dict["repeated_key"] == ["new value", "repeated_key_value2"]
assert inline.rename("repeated_key", "old_key") is True
assert inline.dict["old_key"] == ["new value", "repeated_key_value2"]
assert "repeated_key" not in inline.dict
assert inline.has_changes() is True
def test_inline_metadata_delete() -> None:
"""Test inline metadata delete."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.dict == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.delete("no key") is False
assert inline.delete("repeated_key", "no value") is False
assert inline.has_changes() is False
assert inline.delete("repeated_key", "repeated_key_value1") is True
assert inline.dict["repeated_key"] == ["repeated_key_value2"]
assert inline.delete("repeated_key") is True
assert "repeated_key" not in inline.dict
assert inline.has_changes() is True
assert inline.delete(r"\d{3}") is False
assert inline.delete(r"bold_key\d") is True
assert inline.dict == {
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"tag_key": ["tag_key_value"],
}
assert inline.delete("emoji_📅_key", ".*📅.*") is True
assert inline.dict == {
"emoji_📅_key": [],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"tag_key": ["tag_key_value"],
}
assert tags.contains(r"\w_\w", is_regex=True) is True
assert tags.contains(r"\d_\d", is_regex=True) is False
def test_inline_tags_create() -> None:
@@ -431,40 +115,6 @@ def test_inline_tags_create() -> None:
]
def test_inline_tags_contains() -> None:
"""Test inline tags contains."""
tags = InlineTags(INLINE_CONTENT)
assert tags.contains("bold_tag") is True
assert tags.contains("no tag") is False
assert tags.contains(r"\w_\w", is_regex=True) is True
assert tags.contains(r"\d_\d", is_regex=True) is False
def test_inline_tags_rename() -> None:
"""Test inline tags rename."""
tags = InlineTags(INLINE_CONTENT)
assert tags.list == [
"bold_tag",
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"tag_key_value",
]
assert tags.rename("no tag", "new tag") is False
assert tags.has_changes() is False
assert tags.rename("bold_tag", "new tag") is True
assert tags.list == [
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"new tag",
"tag_key_value",
]
assert tags.has_changes() is True
def test_inline_tags_delete() -> None:
"""Test inline tags delete."""
tags = InlineTags(INLINE_CONTENT)
@@ -489,3 +139,255 @@ def test_inline_tags_delete() -> None:
assert tags.delete(r"\d{3}") is False
assert tags.delete(r"inline_tag_top\d") is True
assert tags.list == ["in_text_tag", "tag_key_value"]
def test_inline_tags_rename() -> None:
"""Test inline tags rename."""
tags = InlineTags(INLINE_CONTENT)
assert tags.list == [
"bold_tag",
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"tag_key_value",
]
assert tags.rename("no tag", "new tag") is False
assert tags.has_changes() is False
assert tags.rename("bold_tag", "new tag") is True
assert tags.list == [
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"new tag",
"tag_key_value",
]
assert tags.has_changes() is True
def test_vault_metadata() -> None:
"""Test VaultMetadata class."""
vm = VaultMetadata()
assert vm.dict == {}
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=METADATA)
vm.index_metadata(area=MetadataType.INLINE, metadata=METADATA_2)
vm.index_metadata(area=MetadataType.TAGS, metadata=TAG_LIST)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"key1": ["value1"],
"key2": ["value2", "value3"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.frontmatter == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.inline_metadata == {"key1": ["value1"], "key2": ["value2", "value3"]}
assert vm.tags == ["tag 1", "tag 2", "tag 3"]
new_metadata = {"added_key": ["added_value"], "frontmatter_Key2": ["new_value"]}
new_tags = ["tag 4", "tag 5"]
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=new_metadata)
vm.index_metadata(area=MetadataType.TAGS, metadata=new_tags)
assert vm.dict == {
"added_key": ["added_value"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "new_value", "note"],
"intext_key": ["intext_key_value"],
"key1": ["value1"],
"key2": ["value2", "value3"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.frontmatter == {
"added_key": ["added_value"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "new_value", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.inline_metadata == {"key1": ["value1"], "key2": ["value2", "value3"]}
assert vm.tags == ["tag 1", "tag 2", "tag 3", "tag 4", "tag 5"]
def test_vault_metadata_print(capsys) -> None:
"""Test print_metadata method."""
vm = VaultMetadata()
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=METADATA)
vm.index_metadata(area=MetadataType.INLINE, metadata=METADATA_2)
vm.index_metadata(area=MetadataType.TAGS, metadata=TAG_LIST)
vm.print_metadata(area=MetadataType.ALL)
captured = remove_ansi(capsys.readouterr().out)
assert "All metadata" in captured
assert "All inline tags" in captured
assert "┃ Keys ┃ Values ┃" in captured
assert "│ shared_key1 │ shared_key1_value │" in captured
assert captured == Regex("#tag 1 +#tag 2")
vm.print_metadata(area=MetadataType.FRONTMATTER)
captured = remove_ansi(capsys.readouterr().out)
assert "All frontmatter" in captured
assert "┃ Keys ┃ Values ┃" in captured
assert "│ shared_key1 │ shared_key1_value │" in captured
assert "value1" not in captured
vm.print_metadata(area=MetadataType.INLINE)
captured = remove_ansi(capsys.readouterr().out)
assert "All inline" in captured
assert "┃ Keys ┃ Values ┃" in captured
assert "shared_key1" not in captured
assert "│ key1 │ value1 │" in captured
vm.print_metadata(area=MetadataType.TAGS)
captured = remove_ansi(capsys.readouterr().out)
assert "All inline tags " in captured
assert "┃ Keys ┃ Values ┃" not in captured
assert captured == Regex("#tag 1 +#tag 2")
vm.print_metadata(area=MetadataType.KEYS)
captured = remove_ansi(capsys.readouterr().out)
assert "All Keys " in captured
assert "┃ Keys ┃ Values ┃" not in captured
assert captured != Regex("#tag 1 +#tag 2")
assert captured == Regex("frontmatter_Key1 +frontmatter_Key2")
def test_vault_metadata_contains() -> None:
"""Test contains method."""
vm = VaultMetadata()
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=METADATA)
vm.index_metadata(area=MetadataType.INLINE, metadata=METADATA_2)
vm.index_metadata(area=MetadataType.TAGS, metadata=TAG_LIST)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"key1": ["value1"],
"key2": ["value2", "value3"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.frontmatter == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.inline_metadata == {"key1": ["value1"], "key2": ["value2", "value3"]}
assert vm.tags == ["tag 1", "tag 2", "tag 3"]
with pytest.raises(ValueError):
vm.contains(area=MetadataType.ALL, value="key1")
assert vm.contains(area=MetadataType.ALL, key="no_key") is False
assert vm.contains(area=MetadataType.ALL, key="key1") is True
assert vm.contains(area=MetadataType.ALL, key="frontmatter_Key2", value="article") is True
assert vm.contains(area=MetadataType.ALL, key="frontmatter_Key2", value="none") is False
assert vm.contains(area=MetadataType.ALL, key="1$", is_regex=True) is True
assert vm.contains(area=MetadataType.ALL, key=r"\d\d", is_regex=True) is False
assert vm.contains(area=MetadataType.FRONTMATTER, key="no_key") is False
assert vm.contains(area=MetadataType.FRONTMATTER, key="frontmatter_Key1") is True
assert (
vm.contains(area=MetadataType.FRONTMATTER, key="frontmatter_Key2", value="article") is True
)
assert vm.contains(area=MetadataType.FRONTMATTER, key="frontmatter_Key2", value="none") is False
assert vm.contains(area=MetadataType.FRONTMATTER, key="1$", is_regex=True) is True
assert vm.contains(area=MetadataType.FRONTMATTER, key=r"\d\d", is_regex=True) is False
assert vm.contains(area=MetadataType.INLINE, key="no_key") is False
assert vm.contains(area=MetadataType.INLINE, key="key1") is True
assert vm.contains(area=MetadataType.INLINE, key="key2", value="value3") is True
assert vm.contains(area=MetadataType.INLINE, key="key2", value="none") is False
assert vm.contains(area=MetadataType.INLINE, key="1$", is_regex=True) is True
assert vm.contains(area=MetadataType.INLINE, key=r"\d\d", is_regex=True) is False
assert vm.contains(area=MetadataType.TAGS, value="no_tag") is False
assert vm.contains(area=MetadataType.TAGS, value="tag 1") is True
assert vm.contains(area=MetadataType.TAGS, value=r"\w+ \d$", is_regex=True) is True
assert vm.contains(area=MetadataType.TAGS, value=r"\w+ \d\d$", is_regex=True) is False
with pytest.raises(ValueError):
vm.contains(area=MetadataType.TAGS, key="key1")
def test_vault_metadata_delete() -> None:
"""Test delete method."""
vm = VaultMetadata()
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=METADATA)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.delete("no key") is False
assert vm.delete("tags", "no value") is False
assert vm.delete("tags", "tag 2") is True
assert vm.dict["tags"] == ["tag 1", "tag 3"]
assert vm.delete("tags") is True
assert "tags" not in vm.dict
def test_vault_metadata_rename() -> None:
"""Test rename method."""
vm = VaultMetadata()
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=METADATA)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.rename("no key", "new key") is False
assert vm.rename("tags", "no tag", "new key") is False
assert vm.rename("tags", "tag 2", "new tag") is True
assert vm.dict["tags"] == ["new tag", "tag 1", "tag 3"]
assert vm.rename("tags", "old_tags") is True
assert vm.dict["old_tags"] == ["new tag", "tag 1", "tag 3"]
assert "tags" not in vm.dict

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -26,7 +26,6 @@ def test_validate_valid_regex() -> None:
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 cannot be empty" in questions._validate_valid_vault_regex("")
assert "Regex does not match paths" in questions._validate_valid_vault_regex(r"\d\d\d\w\d")
@@ -61,6 +60,14 @@ def test_validate_new_tag() -> None:
assert questions._validate_new_tag("new_tag") is True
def test_validate_number() -> None:
"""Test number validation."""
questions = Questions(vault=VAULT)
assert "Must be an integer" in questions._validate_number("test")
assert "Must be an integer" in questions._validate_number("1.1")
assert questions._validate_number("1") is True
def test_validate_existing_inline_tag() -> None:
"""Test existing tag validation."""
questions = Questions(vault=VAULT)
@@ -81,12 +88,10 @@ def test_validate_key_exists_regex() -> None:
def test_validate_value() -> None:
"""Test value validation."""
questions = Questions(vault=VAULT)
assert questions._validate_value("test") is True
assert "Value cannot be empty" in questions._validate_value("")
assert questions._validate_value("test") is True
questions2 = Questions(vault=VAULT, key="frontmatter_Key1")
assert questions2._validate_value("test") == "frontmatter_Key1:test does not exist"
assert "Value cannot be empty" in questions2._validate_value("")
assert questions2._validate_value("author name") is True
@@ -105,7 +110,7 @@ def test_validate_value_exists_regex() -> None:
def test_validate_new_value() -> None:
"""Test new value validation."""
questions = Questions(vault=VAULT, key="frontmatter_Key1")
assert questions._validate_new_value("new_value") is True
assert questions._validate_new_value("not_exists") is True
assert "Value cannot be empty" in questions._validate_new_value("")
assert (
questions._validate_new_value("author name")

View File

@@ -1,13 +1,18 @@
# type: ignore
"""Test the utilities module."""
import pytest
import typer
from obsidian_metadata._utils import (
clean_dictionary,
dict_contains,
dict_keys_to_lower,
dict_values_to_lists_strings,
remove_markdown_sections,
validate_csv_bulk_imports,
)
from tests.helpers import Regex, remove_ansi
def test_dict_contains() -> None:
@@ -25,6 +30,17 @@ def test_dict_contains() -> None:
assert dict_contains(d, r"key\d", "value5", is_regex=True) is True
def test_dict_keys_to_lower() -> None:
"""Test the dict_keys_to_lower() function.
GIVEN a dictionary with mixed case keys
WHEN the dict_keys_to_lower() function is called
THEN the dictionary keys should be converted to lowercase
"""
test_dict = {"Key1": "Value1", "KEY2": "Value2", "key3": "Value3"}
assert dict_keys_to_lower(test_dict) == {"key1": "Value1", "key2": "Value2", "key3": "Value3"}
def test_dict_values_to_lists_strings():
"""Test converting dictionary values to lists of strings."""
dictionary = {
@@ -106,3 +122,125 @@ def test_clean_dictionary():
new_dict = clean_dictionary(dictionary)
assert new_dict == {"key": ["value", "value2", "value3"]}
def test_validate_csv_bulk_imports_1(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file missing the `path` column
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
PATH,type,key,value
note1.md,type,key,value"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_2(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file missing the `type` column
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,Type,key,value
note1.md,type,key,value"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_3(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file missing the `key` column
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,value
note1.md,type,key,value"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_4(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file missing the `value` column
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,key,values
note1.md,type,key,value"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_5(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file with only headers
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = "path,type,key,value"
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_6(tmp_path, capsys):
"""Test the validate_csv_bulk_imports function.
GIVEN a valid csv file
WHEN a path is given that does not exist in the vault
THEN show the user a warning
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,key,value
note1.md,type,key,value
note2.md,type,key,value
"""
csv_path.write_text(csv_content)
csv_dict = validate_csv_bulk_imports(csv_path=csv_path, note_paths=["note1.md"])
captured = remove_ansi(capsys.readouterr().out)
assert "WARNING | 'note2.md' does not exist in vault." in captured
assert csv_dict == {"note1.md": [{"key": "key", "type": "type", "value": "value"}]}
def test_validate_csv_bulk_imports_7(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a valid csv file
WHEN no paths match paths in the vault
THEN exit the program
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,key,value
note1.md,type,key,value
note2.md,type,key,value
"""
csv_path.write_text(csv_content)
with pytest.raises(typer.Exit):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])

View File

@@ -3,89 +3,255 @@
from pathlib import Path
import pytest
import typer
from rich import print
from obsidian_metadata._config import Config
from obsidian_metadata.models import Vault
from obsidian_metadata.models import Vault, VaultFilter
from obsidian_metadata.models.enums import InsertLocation, MetadataType
from tests.helpers import Regex
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_config = config.vaults[0]
vault = Vault(config=vault_config)
def test_vault_creation(test_vault, tmp_path):
"""Test creating a Vault object.
assert vault.vault_path == vault_path
assert vault.backup_path == Path(f"{vault_path}.bak")
GIVEN a Config object
WHEN a Vault object is created
THEN the Vault object is created with the correct attributes.
"""
vault = Vault(config=test_vault)
assert vault.name == "vault"
assert vault.insert_location == InsertLocation.TOP
assert vault.backup_path == Path(tmp_path, "vault.bak")
assert vault.dry_run is False
assert str(vault.exclude_paths[0]) == Regex(r".*\.git")
assert vault.num_notes() == 3
assert len(vault.all_notes) == 2
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"],
"shared_key1": ["shared_key1_value"],
"key📅": ["📅_key_value"],
"shared_key1": [
"shared_key1_value",
"shared_key1_value2",
"shared_key1_value3",
],
"shared_key2": ["shared_key2_value1", "shared_key2_value2"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"frontmatter_tag3",
"ignored_file_tag1",
"shared_tag",
"📅/frontmatter_tag3",
],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value_as_link"],
"type": ["article", "note"],
}
assert vault.metadata.tags == [
"inline_tag_bottom1",
"inline_tag_bottom2",
"inline_tag_top1",
"inline_tag_top2",
"intext_tag1",
"intext_tag2",
"shared_tag",
]
assert vault.metadata.inline_metadata == {
"bottom_key1": ["bottom_key1_value"],
"bottom_key2": ["bottom_key2_value"],
"intext_key": ["intext_value"],
"key📅": ["📅_key_value"],
"shared_key1": ["shared_key1_value", "shared_key1_value2"],
"shared_key2": ["shared_key2_value2"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value_as_link"],
}
assert vault.metadata.frontmatter == {
"date_created": ["2022-12-22"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value", "shared_key1_value3"],
"shared_key2": ["shared_key2_value1"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"shared_tag",
"📅/frontmatter_tag3",
],
}
def test_get_filtered_notes(sample_vault) -> None:
"""Test filtering notes."""
vault_path = sample_vault
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
vault_config = config.vaults[0]
vault = Vault(config=vault_config, path_filter="front")
def set_insert_location(test_vault):
"""Test setting a new insert location.
assert vault.num_notes() == 4
GIVEN a vault object
WHEN the insert location is changed
THEN the insert location is changed
"""
vault = Vault(config=test_vault)
vault_path = sample_vault
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
vault_config = config.vaults[0]
vault2 = Vault(config=vault_config, path_filter="mixed")
assert vault2.num_notes() == 1
assert vault.name == "vault"
assert vault.insert_location == InsertLocation.TOP
vault.insert_location = InsertLocation.BOTTOM
assert vault.insert_location == InsertLocation.BOTTOM
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_config = config.vaults[0]
vault = Vault(config=vault_config)
def test_add_metadata_1(test_vault) -> None:
"""Test adding metadata to the vault.
GIVEN a vault object
WHEN a new metadata key is added
THEN the metadata is added to the vault
"""
vault = Vault(config=test_vault)
assert vault.add_metadata(MetadataType.FRONTMATTER, "new_key") == 2
assert vault.metadata.dict == {
"bottom_key1": ["bottom_key1_value"],
"bottom_key2": ["bottom_key2_value"],
"date_created": ["2022-12-22"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_value"],
"key📅": ["📅_key_value"],
"new_key": [],
"shared_key1": [
"shared_key1_value",
"shared_key1_value2",
"shared_key1_value3",
],
"shared_key2": ["shared_key2_value1", "shared_key2_value2"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"shared_tag",
"📅/frontmatter_tag3",
],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value_as_link"],
}
assert vault.metadata.frontmatter == {
"date_created": ["2022-12-22"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"new_key": [],
"shared_key1": ["shared_key1_value", "shared_key1_value3"],
"shared_key2": ["shared_key2_value1"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"shared_tag",
"📅/frontmatter_tag3",
],
}
def test_add_metadata_2(test_vault) -> None:
"""Test adding metadata to the vault.
GIVEN a vault object
WHEN a new metadata key and value is added
THEN the metadata is added to the vault
"""
vault = Vault(config=test_vault)
assert vault.add_metadata(MetadataType.FRONTMATTER, "new_key2", "new_key2_value") == 2
assert vault.metadata.dict == {
"bottom_key1": ["bottom_key1_value"],
"bottom_key2": ["bottom_key2_value"],
"date_created": ["2022-12-22"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_value"],
"key📅": ["📅_key_value"],
"new_key2": ["new_key2_value"],
"shared_key1": [
"shared_key1_value",
"shared_key1_value2",
"shared_key1_value3",
],
"shared_key2": ["shared_key2_value1", "shared_key2_value2"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"shared_tag",
"📅/frontmatter_tag3",
],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value_as_link"],
}
assert vault.metadata.frontmatter == {
"date_created": ["2022-12-22"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"new_key2": ["new_key2_value"],
"shared_key1": ["shared_key1_value", "shared_key1_value3"],
"shared_key2": ["shared_key2_value1"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"shared_tag",
"📅/frontmatter_tag3",
],
}
def test_commit_changes_1(test_vault, tmp_path):
"""Test committing changes to content in the vault.
GIVEN a vault object
WHEN the commit_changes method is called
THEN the changes are committed to the vault
"""
vault = Vault(config=test_vault)
content = Path(f"{tmp_path}/vault/test1.md").read_text()
assert "new_key: new_key_value" not in content
vault.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_key_value")
vault.commit_changes()
committed_content = Path(f"{tmp_path}/vault/test1.md").read_text()
assert "new_key: new_key_value" in committed_content
def test_commit_changes_2(test_vault, tmp_path):
"""Test committing changes to content in the vault in dry run mode.
GIVEN a vault object
WHEN dry_run is set to True
THEN no changes are committed to the vault
"""
vault = Vault(config=test_vault, dry_run=True)
content = Path(f"{tmp_path}/vault/test1.md").read_text()
assert "new_key: new_key_value" not in content
vault.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_key_value")
vault.commit_changes()
committed_content = Path(f"{tmp_path}/vault/test1.md").read_text()
assert "new_key: new_key_value" not in committed_content
def test_backup_1(test_vault, capsys):
"""Test the backup method.
GIVEN a vault object
WHEN the backup method is called
THEN the vault is backed up
"""
vault = Vault(config=test_vault)
vault.backup()
captured = capsys.readouterr()
assert Path(f"{vault_path}.bak").exists() is True
assert vault.backup_path.exists() is True
assert captured.out == Regex(r"SUCCESS +| backed up to")
vault.info()
@@ -94,14 +260,15 @@ def test_backup(test_vault, capsys):
assert captured.out == Regex(r"Backup path +\│[\s ]+/[\d\w]+")
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_config = config.vaults[0]
vault = Vault(config=vault_config, dry_run=True)
def test_backup_2(test_vault, capsys):
"""Test the backup method.
GIVEN a vault object
WHEN dry_run is set to True and the backup method is called
THEN the vault is not backed up
"""
vault = Vault(config=test_vault, dry_run=True)
print(f"vault.dry_run: {vault.dry_run}")
vault.backup()
captured = capsys.readouterr()
@@ -109,12 +276,14 @@ def test_backup_dryrun(test_vault, capsys):
assert captured.out == Regex(r"DRYRUN +| Backup up vault to")
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_config = config.vaults[0]
vault = Vault(config=vault_config)
def test_delete_backup_1(test_vault, capsys):
"""Test deleting the vault backup.
GIVEN a vault object
WHEN the delete_backup method is called
THEN the backup is deleted
"""
vault = Vault(config=test_vault)
vault.backup()
vault.delete_backup()
@@ -129,12 +298,14 @@ def test_delete_backup(test_vault, capsys):
assert captured.out == Regex(r"Backup +\│ None")
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_config = config.vaults[0]
vault = Vault(config=vault_config, dry_run=True)
def test_delete_backup_2(test_vault, capsys):
"""Test delete_backup method in dry run mode.
GIVEN a vault object
WHEN the dry_run is True and the delete_backup method is called
THEN the backup is not deleted
"""
vault = Vault(config=test_vault, dry_run=True)
Path.mkdir(vault.backup_path)
vault.delete_backup()
@@ -144,56 +315,17 @@ def test_delete_backup_dryrun(test_vault, capsys):
assert vault.backup_path.exists() is True
def test_info(test_vault, capsys):
"""Test printing vault information."""
vault_path = test_vault
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
vault_config = config.vaults[0]
vault = Vault(config=vault_config)
def test_delete_inline_tag_1(test_vault) -> None:
"""Test delete_inline_tag() method.
vault.info()
GIVEN a vault object
WHEN the delete_inline_tag method is called
THEN the inline tag is deleted
"""
vault = Vault(config=test_vault)
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"Backup +\│ None")
def test_contains_inline_tag(test_vault) -> None:
"""Test if the vault contains an inline tag."""
vault_path = test_vault
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
vault_config = config.vaults[0]
vault = Vault(config=vault_config)
assert vault.contains_inline_tag("tag") is False
assert vault.contains_inline_tag("intext_tag2") is True
def test_contains_metadata(test_vault) -> None:
"""Test if the vault contains a metadata key."""
vault_path = test_vault
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
vault_config = config.vaults[0]
vault = Vault(config=vault_config)
assert vault.contains_metadata("key") is False
assert vault.contains_metadata("top_key1") is True
assert vault.contains_metadata("top_key1", "no_value") is False
assert vault.contains_metadata("top_key1", "top_key1_value") is True
def test_delete_inline_tag(test_vault) -> None:
"""Test deleting an inline tag."""
vault_path = test_vault
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.delete_inline_tag("no tag") == 0
assert vault.delete_inline_tag("intext_tag2") == 2
assert vault.metadata.dict["Inline Tags"] == [
"ignored_file_tag2",
assert vault.delete_inline_tag("intext_tag2") == 1
assert vault.metadata.tags == [
"inline_tag_bottom1",
"inline_tag_bottom2",
"inline_tag_top1",
@@ -203,34 +335,276 @@ def test_delete_inline_tag(test_vault) -> None:
]
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_config = config.vaults[0]
vault = Vault(config=vault_config)
def test_delete_inline_tag_2(test_vault) -> None:
"""Test delete_inline_tag() method.
GIVEN a vault object
WHEN the delete_inline_tag method is called with a tag that does not exist
THEN no changes are made
"""
vault = Vault(config=test_vault)
assert vault.delete_inline_tag("no tag") == 0
def test_delete_metadata_1(test_vault) -> None:
"""Test deleting a metadata key/value.
GIVEN a vault object
WHEN the delete_metadata method is called with a key and value
THEN the specified metadata key/value is deleted
"""
vault = Vault(config=test_vault)
assert vault.delete_metadata("top_key1", "top_key1_value") == 1
assert vault.metadata.dict["top_key1"] == []
def test_delete_metadata_2(test_vault) -> None:
"""Test deleting a metadata key/value.
GIVEN a vault object
WHEN the delete_metadata method is called with a key
THEN the specified metadata key is deleted
"""
vault = Vault(config=test_vault)
assert vault.delete_metadata("top_key2") == 1
assert "top_key2" not in vault.metadata.dict
def test_delete_metadata_3(test_vault) -> None:
"""Test deleting a metadata key/value.
GIVEN a vault object
WHEN the delete_metadata method is called with a key and/or value that does not exist
THEN no changes are made
"""
vault = Vault(config=test_vault)
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") == 2
assert vault.metadata.dict["top_key1"] == []
assert vault.delete_metadata("top_key2") == 2
assert "top_key2" not in vault.metadata.dict
def test_export_csv_1(tmp_path, test_vault):
"""Test exporting the vault to a CSV file.
GIVEN a vault object
WHEN the export_metadata method is called with a path and export_format of csv
THEN the vault metadata is exported to a CSV file
"""
vault = Vault(config=test_vault)
export_file = Path(f"{tmp_path}/export.csv")
vault.export_metadata(path=export_file, export_format="csv")
assert export_file.exists() is True
assert "frontmatter,date_created,2022-12-22" in export_file.read_text()
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)
def test_export_csv_2(tmp_path, test_vault):
"""Test exporting the vault to a CSV file.
GIVEN a vault object
WHEN the export_metadata method is called with a path that does not exist and export_format of csv
THEN an error is raised
"""
vault = Vault(config=test_vault)
export_file = Path(f"{tmp_path}/does_not_exist/export.csv")
with pytest.raises(typer.Exit):
vault.export_metadata(path=export_file, export_format="csv")
assert export_file.exists() is False
def test_export_json(tmp_path, test_vault):
"""Test exporting the vault to a JSON file.
GIVEN a vault object
WHEN the export_metadata method is called with a path and export_format of csv
THEN the vault metadata is exported to a JSON file
"""
vault = Vault(config=test_vault)
export_file = Path(f"{tmp_path}/export.json")
vault.export_metadata(path=export_file, export_format="json")
assert export_file.exists() is True
assert '"frontmatter": {' in export_file.read_text()
def test_export_notes_to_csv_1(tmp_path, test_vault):
"""Test export_notes_to_csv() method.
GIVEN a vault object
WHEN the export_notes_to_csv method is called with a path
THEN the notes are exported to a CSV file
"""
vault = Vault(config=test_vault)
export_file = Path(f"{tmp_path}/export.csv")
vault.export_notes_to_csv(path=export_file)
assert export_file.exists() is True
assert "path,type,key,value" in export_file.read_text()
assert "test1.md,frontmatter,shared_key1,shared_key1_value" in export_file.read_text()
assert "test1.md,inline_metadata,shared_key1,shared_key1_value" in export_file.read_text()
assert "test1.md,tag,,shared_tag" in export_file.read_text()
assert "test1.md,frontmatter,tags,📅/frontmatter_tag3" in export_file.read_text()
assert "test1.md,inline_metadata,key📅,📅_key_value" in export_file.read_text()
def test_export_notes_to_csv_2(test_vault):
"""Test export_notes_to_csv() method.
GIVEN a vault object
WHEN the export_notes_to_csv method is called with a path where the parent directory does not exist
THEN an error is raised
"""
vault = Vault(config=test_vault)
export_file = Path("/I/do/not/exist/export.csv")
with pytest.raises(typer.Exit):
vault.export_notes_to_csv(path=export_file)
def test_get_filtered_notes_1(sample_vault) -> None:
"""Test filtering notes.
GIVEN a vault object
WHEN the get_filtered_notes method is called with a path filter
THEN the notes in scope are filtered
"""
vault_path = sample_vault
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
vault_config = config.vaults[0]
vault = Vault(config=vault_config)
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",
filters = [VaultFilter(path_filter="front")]
vault = Vault(config=vault_config, filters=filters)
assert len(vault.all_notes) == 13
assert len(vault.notes_in_scope) == 4
filters = [VaultFilter(path_filter="mixed")]
vault = Vault(config=vault_config, filters=filters)
assert len(vault.all_notes) == 13
assert len(vault.notes_in_scope) == 1
def test_get_filtered_notes_2(sample_vault) -> None:
"""Test filtering notes.
GIVEN a vault object
WHEN the get_filtered_notes method is called with a key filter
THEN the notes in scope are filtered
"""
vault_path = sample_vault
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
vault_config = config.vaults[0]
filters = [VaultFilter(key_filter="on_one_note")]
vault = Vault(config=vault_config, filters=filters)
assert len(vault.all_notes) == 13
assert len(vault.notes_in_scope) == 1
def test_get_filtered_notes_3(sample_vault) -> None:
"""Test filtering notes.
GIVEN a vault object
WHEN the get_filtered_notes method is called with a key and a value filter
THEN the notes in scope are filtered
"""
vault_path = sample_vault
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
vault_config = config.vaults[0]
filters = [VaultFilter(key_filter="type", value_filter="book")]
vault = Vault(config=vault_config, filters=filters)
assert len(vault.all_notes) == 13
assert len(vault.notes_in_scope) == 10
def test_get_filtered_notes_4(sample_vault) -> None:
"""Test filtering notes.
GIVEN a vault object
WHEN the get_filtered_notes method is called with a tag filter
THEN the notes in scope are filtered
"""
vault_path = sample_vault
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
vault_config = config.vaults[0]
filters = [VaultFilter(tag_filter="brunch")]
vault = Vault(config=vault_config, filters=filters)
assert len(vault.all_notes) == 13
assert len(vault.notes_in_scope) == 1
def test_get_filtered_notes_5(sample_vault) -> None:
"""Test filtering notes.
GIVEN a vault object
WHEN the get_filtered_notes method is called with a tag and a path filter
THEN the notes in scope are filtered
"""
vault_path = sample_vault
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
vault_config = config.vaults[0]
filters = [VaultFilter(tag_filter="brunch"), VaultFilter(path_filter="inbox")]
vault = Vault(config=vault_config, filters=filters)
assert len(vault.all_notes) == 13
assert len(vault.notes_in_scope) == 0
def test_info(test_vault, capsys):
"""Test info() method.
GIVEN a vault object
WHEN the info method is called
THEN the vault info is printed
"""
vault = Vault(config=test_vault)
vault.info()
captured = capsys.readouterr()
assert captured.out == Regex(r"Vault +\│ /[\d\w]+")
assert captured.out == Regex(r"Notes in scope +\\d+")
assert captured.out == Regex(r"Backup +\│ None")
def test_list_editable_notes(test_vault, capsys) -> None:
"""Test list_editable_notes() method.
GIVEN a vault object
WHEN the list_editable_notes() method is called
THEN the editable notes in scope are printed
"""
vault = Vault(config=test_vault)
vault.list_editable_notes()
captured = capsys.readouterr()
assert captured.out == Regex("Notes in current scope")
assert captured.out == Regex(r"\d +test1\.md")
def test_move_inline_metadata_1(test_vault) -> None:
"""Test move_inline_metadata() method.
GIVEN a vault with inline metadata.
WHEN the move_inline_metadata() method is called.
THEN the inline metadata is moved to the top of the file.
"""
vault = Vault(config=test_vault)
assert vault.move_inline_metadata(location=InsertLocation.TOP) == 1
def test_rename_inline_tag_1(test_vault) -> None:
"""Test rename_inline_tag() method.
GIVEN a vault object
WHEN the rename_inline_tag() method is called with a tag that is found
THEN the inline tag is renamed
"""
vault = Vault(config=test_vault)
assert vault.rename_inline_tag("intext_tag2", "new_tag") == 1
assert vault.metadata.tags == [
"inline_tag_bottom1",
"inline_tag_bottom2",
"inline_tag_top1",
@@ -241,33 +615,165 @@ def test_rename_inline_tag(test_vault) -> None:
]
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_config = config.vaults[0]
vault = Vault(config=vault_config)
def test_rename_inline_tag_2(test_vault) -> None:
"""Test rename_inline_tag() method.
GIVEN a vault object
WHEN the rename_inline_tag() method is called with a tag that is not found
THEN the inline tag is not renamed
"""
vault = Vault(config=test_vault)
assert vault.rename_inline_tag("no tag", "new_tag") == 0
def test_rename_metadata_1(test_vault) -> None:
"""Test rename_metadata() method.
GIVEN a vault object
WHEN the rename_metadata() method is called with a key or key/value that is found
THEN the metadata is not renamed
"""
vault = Vault(config=test_vault)
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") == 2
def test_rename_metadata_2(test_vault) -> None:
"""Test rename_metadata() method.
GIVEN a vault object
WHEN the rename_metadata() method with a key and no value
THEN the metadata key is renamed
"""
vault = Vault(config=test_vault)
assert vault.rename_metadata("tags", "new_key") == 1
assert "tags" not in vault.metadata.dict
assert vault.metadata.dict["new_key"] == [
"frontmatter_tag1",
"frontmatter_tag2",
"shared_tag",
"📅/frontmatter_tag3",
]
def test_rename_metadata_3(test_vault) -> None:
"""Test rename_metadata() method.
GIVEN a vault object
WHEN the rename_metadata() method is called with a key and value
THEN the metadata key/value is renamed
"""
vault = Vault(config=test_vault)
assert vault.rename_metadata("tags", "frontmatter_tag1", "new_vaule") == 1
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") == 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",
]
def test_transpose_metadata(test_vault) -> None:
"""Test transpose_metadata() method.
GIVEN a vault object
WHEN the transpose_metadata() method is called
THEN the metadata is transposed
"""
vault = Vault(config=test_vault)
assert vault.transpose_metadata(begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER) == 1
assert vault.metadata.inline_metadata == {}
assert vault.metadata.frontmatter == {
"bottom_key1": ["bottom_key1_value"],
"bottom_key2": ["bottom_key2_value"],
"date_created": ["2022-12-22"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_value"],
"key📅": ["📅_key_value"],
"shared_key1": [
"shared_key1_value",
"shared_key1_value2",
"shared_key1_value3",
],
"shared_key2": ["shared_key2_value1", "shared_key2_value2"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"shared_tag",
"📅/frontmatter_tag3",
],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value_as_link"],
}
assert (
vault.transpose_metadata(
begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER, location=InsertLocation.TOP
)
== 0
)
def test_update_from_dict_1(test_vault):
"""Test update_from_dict() method.
GIVEN a vault object and an update dictionary
WHEN no dictionary keys match paths in the vault
THEN no notes are updated and 0 is returned
"""
vault = Vault(config=test_vault)
update_dict = {
"path1": {"type": "frontmatter", "key": "new_key", "value": "new_value"},
"path2": {"type": "frontmatter", "key": "new_key", "value": "new_value"},
}
assert vault.update_from_dict(update_dict) == 0
assert vault.get_changed_notes() == []
def test_update_from_dict_2(test_vault):
"""Test update_from_dict() method.
GIVEN a vault object and an update dictionary
WHEN the dictionary is empty
THEN no notes are updated and 0 is returned
"""
vault = Vault(config=test_vault)
update_dict = {}
assert vault.update_from_dict(update_dict) == 0
assert vault.get_changed_notes() == []
def test_update_from_dict_3(test_vault):
"""Test update_from_dict() method.
GIVEN a vault object and an update dictionary
WHEN a dictionary key matches a path in the vault
THEN the note is updated to match the dictionary values
"""
vault = Vault(config=test_vault)
update_dict = {
"test1.md": [
{"type": "frontmatter", "key": "new_key", "value": "new_value"},
{"type": "inline_metadata", "key": "new_key2", "value": "new_value"},
{"type": "tags", "key": "", "value": "new_tag"},
]
}
assert vault.update_from_dict(update_dict) == 1
assert vault.get_changed_notes()[0].note_path.name == "test1.md"
assert vault.get_changed_notes()[0].frontmatter.dict == {"new_key": ["new_value"]}
assert vault.get_changed_notes()[0].inline_metadata.dict == {"new_key2": ["new_value"]}
assert vault.get_changed_notes()[0].inline_tags.list == ["new_tag"]
assert vault.metadata.frontmatter == {"new_key": ["new_value"]}
assert vault.metadata.inline_metadata == {"new_key2": ["new_value"]}
assert vault.metadata.tags == ["new_tag"]