mirror of
https://github.com/natelandau/obsidian-metadata.git
synced 2025-11-16 08:53:48 -05:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f431353e1 | ||
|
|
4e49445b08 | ||
|
|
5f9c79a9c1 | ||
|
|
34e7c07dd9 | ||
|
|
32a838c8e4 | ||
|
|
000ac1a16c | ||
|
|
1eb2d30d47 | ||
|
|
b6a3d115fd | ||
|
|
03e6ad59c4 | ||
|
|
0b744f65ee | ||
|
|
bf869cfc15 | ||
|
|
bd4b94aefa | ||
|
|
3932717c7e | ||
|
|
755151e2ed | ||
|
|
8f8174a902 | ||
|
|
3bbcf3a987 | ||
|
|
347dd4271f | ||
|
|
167997f527 | ||
|
|
0143967db8 | ||
|
|
446374b335 | ||
|
|
401d830942 | ||
|
|
7eb8ff5fa8 | ||
|
|
2cca54320c | ||
|
|
d94d9f2197 | ||
|
|
17985615b3 | ||
|
|
13513b2a14 | ||
|
|
b7b77d998c | ||
|
|
0de95a4be4 | ||
|
|
90b737f7b3 | ||
|
|
8e040aeba4 | ||
|
|
4a29945de2 | ||
|
|
6909738218 |
@@ -61,6 +61,7 @@
|
||||
"foxundermoon.shell-format",
|
||||
"GitHub.copilot",
|
||||
"Gruntfuggly.todo-tree",
|
||||
"GrapeCity.gc-excelviewer",
|
||||
"mhutchie.git-graph",
|
||||
"njpwerner.autodocstring",
|
||||
"oderwat.indent-rainbow",
|
||||
|
||||
@@ -54,7 +54,7 @@ _mainScript_() {
|
||||
echo ""
|
||||
header "Installing shfmt"
|
||||
if ! command -v shfmt &>/dev/null; then
|
||||
_execute_ "curl -sS https://webi.sh/shfmt | sh"
|
||||
_execute_ -pv "curl -sS https://webi.sh/shfmt | sh"
|
||||
fi
|
||||
|
||||
REPOS=(
|
||||
|
||||
@@ -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
|
||||
# ----------------------------------------------
|
||||
9
.github/workflows/commit-linter.yml
vendored
9
.github/workflows/commit-linter.yml
vendored
@@ -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: >
|
||||
|
||||
32
.github/workflows/create-release.yml
vendored
32
.github/workflows/create-release.yml
vendored
@@ -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
|
||||
|
||||
15
.github/workflows/devcontainer-checker.yml
vendored
15
.github/workflows/devcontainer-checker.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
@@ -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: >
|
||||
|
||||
2
.github/workflows/pr-linter.yml
vendored
2
.github/workflows/pr-linter.yml
vendored
@@ -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: >
|
||||
|
||||
2
.github/workflows/pypi-release.yml
vendored
2
.github/workflows/pypi-release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.237"
|
||||
rev: "v0.0.254"
|
||||
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"
|
||||
|
||||
77
CHANGELOG.md
77
CHANGELOG.md
@@ -1,32 +1,95 @@
|
||||
## 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)
|
||||
- **application**: add new metadata to frontmatter (#9)
|
||||
|
||||
### Fix
|
||||
|
||||
- **application**: improve ux (#10)
|
||||
- **application**: improve ux (#10)
|
||||
|
||||
## v0.2.0 (2023-01-25)
|
||||
|
||||
### Feat
|
||||
|
||||
- **configuration**: support multiple vaults in the configuration file (#6)
|
||||
- **configuration**: support multiple vaults in the configuration file (#6)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **application**: refactor questions to separate class (#7)
|
||||
- **application**: refactor questions to separate class (#7)
|
||||
|
||||
## v0.1.1 (2023-01-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- **notes**: diff now prints values in the form `[value]`
|
||||
- **application**: exit after committing changes
|
||||
- **notes**: diff now prints values in the form `[value]`
|
||||
- **application**: exit after committing changes
|
||||
|
||||
## v0.1.0 (2023-01-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- initial application release
|
||||
- initial application release
|
||||
|
||||
116
README.md
116
README.md
@@ -1,15 +1,18 @@
|
||||
[](https://github.com/natelandau/obsidian-metadata/actions/workflows/python-code-checker.yml) [](https://codecov.io/gh/natelandau/obsidian-metadata)
|
||||
# obsidian-metadata
|
||||
A script to make batch updates to metadata in an Obsidian vault. No changes are
|
||||
made to the Vault until they are explicitly committed.
|
||||
[](https://badge.fury.io/py/obsidian-metadata)  [](https://github.com/natelandau/obsidian-metadata/actions/workflows/automated-tests.yml) [](https://codecov.io/gh/natelandau/obsidian-metadata)
|
||||
|
||||
[](https://asciinema.org/a/555789)
|
||||
# obsidian-metadata
|
||||
|
||||
A script to make batch updates to metadata in an Obsidian vault. No changes are
|
||||
made to the Vault until they are explicitly committed.
|
||||
|
||||
[](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.
|
||||
|
||||
**It is strongly recommended that you back up your vault prior to committing changes.** This script makes changes directly to the markdown files in your vault. Once the changes are committed, there is no ability to recreate the original information unless you have a backup. Follow the instructions in the script to create a backup of your vault if needed. The author of this script is not responsible for any data loss that may occur. Use at your own risk.
|
||||
|
||||
## Install
|
||||
|
||||
Requires Python v3.10 or above.
|
||||
|
||||
```bash
|
||||
@@ -17,45 +20,82 @@ pip install obsidian-metadata
|
||||
```
|
||||
|
||||
## Usage
|
||||
Run `obsidian-metadata` from the command line to invoke the script. Add `--help` to view additional options.
|
||||
|
||||
Obsidian-metadata provides a menu of sub-commands.
|
||||
### CLI Commands
|
||||
|
||||
- `--config-file`: Specify a custom configuration file location
|
||||
- `--dry-run`: Make no destructive changes
|
||||
- `--export-csv`: Specify a path and create a CSV export of all metadata
|
||||
- `--export-json`: Specify a path and create a JSON export of all metadata
|
||||
- `--help`: Shows interactive help and exits
|
||||
- `--log-file`: Specify a log file location
|
||||
- `--log-to-file`: Will log to a file
|
||||
- `--vault-path`: Specify a path to an Obsidian Vault
|
||||
- `--verbose`: Set verbosity level (0=WARN, 1=INFO, 2=DEBUG, 3=TRACE)
|
||||
- `--version`: Prints the version number and exits
|
||||
|
||||
### Running the script
|
||||
|
||||
Once installed, run `obsidian-metadata` in your terminal to enter an interactive menu of sub-commands.
|
||||
|
||||
**Vault Actions**
|
||||
- Backup: Create a backup of the vault.
|
||||
- Delete Backup: Delete a backup of the vault.
|
||||
|
||||
- Backup: Create a backup of the vault.
|
||||
- Delete Backup: Delete a backup of the vault.
|
||||
|
||||
**Inspect Metadata**
|
||||
- View all metadata in the vault
|
||||
|
||||
**Filter Notes in Scope**:
|
||||
Limit the scope of notes to be processed with a regex.
|
||||
- Apply regex: Set a regex to limit scope
|
||||
- List notes in scope: List notes that will be processed.
|
||||
- **View all metadata in the vault**
|
||||
- View all **frontmatter**
|
||||
- View all **inline metadata**
|
||||
- View all **inline tags**
|
||||
- **Export all metadata to CSV or JSON file**
|
||||
|
||||
**Add Metadata**
|
||||
- Add metadata to the frontmatter
|
||||
- Add to inline metadata (Not yet implemented)
|
||||
- Add to inline tag (Not yet implemented)
|
||||
**Filter Notes in Scope**: Limit the scope of notes to be processed with one or more filters.
|
||||
|
||||
**Rename Metadata**
|
||||
- Rename a key
|
||||
- Rename a value
|
||||
- rename an inline tag
|
||||
- **Path filter (regex)**: Limit scope based on the path or filename
|
||||
- **Metadata filter**: Limit scope based on a key or key/value pair
|
||||
- **Tag filter**: Limit scope based on an in-text tag
|
||||
- **List and clear filters**: List all current filters and clear one or all
|
||||
- **List notes in scope**: List notes that will be processed.
|
||||
|
||||
**Delete Metadata**
|
||||
- Delete a key and associated values
|
||||
- Delete a value from a key
|
||||
- Delete an inline tag
|
||||
**Add Metadata**: Add new metadata to your vault.
|
||||
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.
|
||||
|
||||
**Review Changes**
|
||||
- View a diff of the changes that will be made
|
||||
- **Add new metadata to the frontmatter**
|
||||
- **Add new inline metadata** - Set `insert_location` in the config to control where the new metadata is inserted. (Default: Bottom)
|
||||
- **Add new inline tag** - Set `insert_location` in the config to control where the new tag is inserted. (Default: Bottom)
|
||||
|
||||
**Commit Changes**
|
||||
- Commit changes to the vault
|
||||
**Rename Metadata**: Rename either a key and all associated values, a specific value within a key. or an in-text tag.
|
||||
|
||||
- **Rename a key**
|
||||
- **Rename a value**
|
||||
- **Rename an inline tag**
|
||||
|
||||
**Delete Metadata**: Delete either a key and all associated values, or a specific value.
|
||||
|
||||
- **Delete a key and associated values**
|
||||
- **Delete a value from a key**
|
||||
- **Delete an inline tag**
|
||||
|
||||
**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
|
||||
|
||||
@@ -70,21 +110,27 @@ 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.
|
||||
|
||||
|
||||
# Contributing
|
||||
|
||||
## Setup: Once per project
|
||||
|
||||
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
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
1734
poetry.lock
generated
1734
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,2 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
in-project = true
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
name = "obsidian-metadata"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/natelandau/obsidian-metadata"
|
||||
version = "0.3.0"
|
||||
version = "0.7.0"
|
||||
|
||||
[tool.poetry.scripts] # https://python-poetry.org/docs/pyproject/#scripts
|
||||
obsidian-metadata = "obsidian_metadata.cli:app"
|
||||
@@ -20,35 +20,33 @@
|
||||
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"
|
||||
|
||||
[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.0"
|
||||
|
||||
[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.1"
|
||||
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"
|
||||
mypy = "^1.1.1"
|
||||
pdoc = "^13.0.0"
|
||||
poethepoet = "^0.18.1"
|
||||
pre-commit = "^3.1.1"
|
||||
pysnooper = "^1.1.1"
|
||||
ruff = "^0.0.217"
|
||||
ruff = "^0.0.254"
|
||||
typeguard = "^2.13.3"
|
||||
types-python-dateutil = "^2.8.19.5"
|
||||
types-pyyaml = "^6.0.12.2"
|
||||
types-python-dateutil = "^2.8.19.10"
|
||||
vulture = "^2.7"
|
||||
|
||||
[tool.ruff] # https://github.com/charliermarsh/ruff
|
||||
@@ -75,6 +73,7 @@
|
||||
]
|
||||
ignore-init-module-imports = true
|
||||
line-length = 100
|
||||
per-file-ignores = { "cli.py" = ["PLR0912", "PLR0913"], "tests/*.py" = ["PLR0913", "PLR2004"] }
|
||||
select = [
|
||||
"A",
|
||||
"B",
|
||||
@@ -139,14 +138,12 @@
|
||||
line-length = 100
|
||||
|
||||
[tool.commitizen]
|
||||
bump_message = "bump(release): v$current_version → v$new_version"
|
||||
tag_format = "v$version"
|
||||
bump_message = "bump(release): v$current_version → v$new_version"
|
||||
changelog_incremental = true
|
||||
tag_format = "v$version"
|
||||
update_changelog_on_bump = true
|
||||
version = "0.3.0"
|
||||
version_files = [
|
||||
"pyproject.toml:version",
|
||||
"src/obsidian_metadata/__version__.py:__version__",
|
||||
]
|
||||
version = "0.7.0"
|
||||
version_files = ["pyproject.toml:version", "src/obsidian_metadata/__version__.py:__version__"]
|
||||
|
||||
[tool.interrogate]
|
||||
exclude = ["build", "docs", "tests"]
|
||||
@@ -206,7 +203,7 @@
|
||||
help = "Lint this package"
|
||||
|
||||
[[tool.poe.tasks.lint.sequence]]
|
||||
shell = "ruff --extend-ignore=I001,D301 src/ tests/"
|
||||
shell = "ruff --extend-ignore=I001,D301,D401 src/"
|
||||
|
||||
[[tool.poe.tasks.lint.sequence]]
|
||||
shell = "black --check src/ tests/"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
"""obsidian-metadata version."""
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.7.0"
|
||||
|
||||
@@ -17,6 +17,21 @@ from obsidian_metadata._utils.alerts import logger as log
|
||||
class ConfigQuestions:
|
||||
"""Questions to ask the user when creating a configuration file."""
|
||||
|
||||
@staticmethod
|
||||
def _validate_valid_dir(path: str) -> bool | str:
|
||||
"""Validate a valid directory.
|
||||
|
||||
Returns:
|
||||
bool | str: True if the path is valid, otherwise a string with the error message.
|
||||
"""
|
||||
path_to_validate: Path = Path(path).expanduser().resolve()
|
||||
if not path_to_validate.exists():
|
||||
return f"Path does not exist: {path_to_validate}"
|
||||
if not path_to_validate.is_dir():
|
||||
return f"Path is not a directory: {path_to_validate}"
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def ask_for_vault_path() -> Path: # pragma: no cover
|
||||
"""Ask the user for the path to their vault.
|
||||
@@ -34,28 +49,12 @@ class ConfigQuestions:
|
||||
|
||||
return Path(vault_path).expanduser().resolve()
|
||||
|
||||
@staticmethod
|
||||
def _validate_valid_dir(path: str) -> bool | str:
|
||||
"""Validates a valid directory.
|
||||
|
||||
Returns:
|
||||
bool | str: True if the path is valid, otherwise a string with the error message.
|
||||
"""
|
||||
path_to_validate: Path = Path(path).expanduser().resolve()
|
||||
if not path_to_validate.exists():
|
||||
return f"Path does not exist: {path_to_validate}"
|
||||
if not path_to_validate.is_dir():
|
||||
return f"Path is not a directory: {path_to_validate}"
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Config:
|
||||
"""Representation of a configuration file."""
|
||||
|
||||
def __init__(self, config_path: Path = None, vault_path: Path = None) -> None:
|
||||
|
||||
if vault_path is None:
|
||||
self.config_path: Path = self._validate_config_path(Path(config_path))
|
||||
self.config: dict[str, Any] = self._load_config()
|
||||
@@ -66,7 +65,11 @@ class Config:
|
||||
else:
|
||||
self.config_path = None
|
||||
self.config = {
|
||||
"command_line_vault": {"path": vault_path, "exclude_paths": [".git", ".obsidian"]}
|
||||
"command_line_vault": {
|
||||
"path": vault_path,
|
||||
"exclude_paths": [".git", ".obsidian"],
|
||||
"insert_location": "BOTTOM",
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -85,6 +88,15 @@ class Config:
|
||||
yield "config_path", self.config_path
|
||||
yield "vaults", self.vaults
|
||||
|
||||
def _load_config(self) -> dict[str, Any]:
|
||||
"""Load the configuration file."""
|
||||
try:
|
||||
with open(self.config_path, encoding="utf-8") as fp:
|
||||
return tomlkit.load(fp)
|
||||
except tomlkit.exceptions.TOMLKitError as e:
|
||||
alerts.error(f"Could not parse '{self.config_path}'")
|
||||
raise typer.Exit(code=1) from e
|
||||
|
||||
def _validate_config_path(self, config_path: Path | None) -> Path:
|
||||
"""Load the configuration path."""
|
||||
if config_path is None:
|
||||
@@ -96,15 +108,6 @@ class Config:
|
||||
|
||||
return config_path.expanduser().resolve()
|
||||
|
||||
def _load_config(self) -> dict[str, Any]:
|
||||
"""Load the configuration file."""
|
||||
try:
|
||||
with open(self.config_path, encoding="utf-8") as fp:
|
||||
return tomlkit.load(fp)
|
||||
except tomlkit.exceptions.TOMLKitError as e:
|
||||
alerts.error(f"Could not parse '{self.config_path}'")
|
||||
raise typer.Exit(code=1) from e
|
||||
|
||||
def _write_default_config(self, path_to_config: Path) -> None:
|
||||
"""Write the default configuration file when no config file is found."""
|
||||
vault_path = ConfigQuestions.ask_for_vault_path()
|
||||
@@ -117,7 +120,14 @@ class Config:
|
||||
path = "{vault_path}"
|
||||
|
||||
# Folders within the vault to ignore when indexing metadata
|
||||
exclude_paths = [".git", ".obsidian"]"""
|
||||
exclude_paths = [".git", ".obsidian"]
|
||||
|
||||
# Location to add 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."""
|
||||
|
||||
@@ -8,6 +8,7 @@ from obsidian_metadata._utils.utilities import (
|
||||
dict_contains,
|
||||
dict_values_to_lists_strings,
|
||||
docstring_parameter,
|
||||
merge_dictionaries,
|
||||
remove_markdown_sections,
|
||||
version_callback,
|
||||
)
|
||||
@@ -20,6 +21,7 @@ __all__ = [
|
||||
"dict_values_to_lists_strings",
|
||||
"docstring_parameter",
|
||||
"LoggerManager",
|
||||
"merge_dictionaries",
|
||||
"remove_markdown_sections",
|
||||
"vault_validation",
|
||||
"version_callback",
|
||||
|
||||
@@ -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,30 @@ 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 = 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:
|
||||
@@ -68,7 +116,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:
|
||||
@@ -124,10 +172,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 +185,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 +195,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 +238,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 +253,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 +268,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 +283,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
|
||||
|
||||
4
src/obsidian_metadata/_utils/console.py
Normal file
4
src/obsidian_metadata/_utils/console.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Rich console object for the application."""
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
@@ -6,14 +6,72 @@ from typing import Any
|
||||
import typer
|
||||
|
||||
from obsidian_metadata.__version__ import __version__
|
||||
from obsidian_metadata._utils.console import console
|
||||
|
||||
|
||||
def dict_values_to_lists_strings(dictionary: dict, strip_null_values: bool = False) -> dict:
|
||||
"""Converts all values in a dictionary to lists of strings.
|
||||
def clean_dictionary(dictionary: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Clean up a dictionary by markdown formatting from keys and values.
|
||||
|
||||
Args:
|
||||
dictionary (dict): Dictionary to clean
|
||||
|
||||
Returns:
|
||||
dict: Cleaned dictionary
|
||||
"""
|
||||
new_dict = {key.strip(): value for key, value in dictionary.items()}
|
||||
new_dict = {key.strip("*[]#"): value for key, value in new_dict.items()}
|
||||
for key, value in new_dict.items():
|
||||
new_dict[key] = [s.strip("*[]#") for s in value if isinstance(value, list)]
|
||||
|
||||
return new_dict
|
||||
|
||||
|
||||
def clear_screen() -> None: # pragma: no cover
|
||||
"""Clear the screen."""
|
||||
# for windows
|
||||
_ = system("cls") if name == "nt" else system("clear")
|
||||
|
||||
|
||||
def dict_contains(
|
||||
dictionary: dict[str, list[str]], key: str, value: str = None, is_regex: bool = False
|
||||
) -> bool:
|
||||
"""Check if a dictionary contains a key or if a specified key contains a value.
|
||||
|
||||
Args:
|
||||
dictionary (dict): Dictionary to check
|
||||
key (str): Key to check for
|
||||
value (str, optional): Value to check for. Defaults to None.
|
||||
is_regex (bool, optional): Whether the key is a regex. Defaults to False.
|
||||
|
||||
Returns:
|
||||
bool: Whether the dictionary contains the key
|
||||
"""
|
||||
if value is None:
|
||||
if is_regex:
|
||||
return any(re.search(key, str(_key)) for _key in dictionary)
|
||||
return key in dictionary
|
||||
|
||||
if is_regex:
|
||||
found_keys = []
|
||||
for _key in dictionary:
|
||||
if re.search(key, str(_key)):
|
||||
found_keys.append(
|
||||
any(re.search(value, _v) for _v in dictionary[_key]),
|
||||
)
|
||||
return any(found_keys)
|
||||
|
||||
return key in dictionary and value in dictionary[key]
|
||||
|
||||
|
||||
def dict_values_to_lists_strings(
|
||||
dictionary: dict,
|
||||
strip_null_values: bool = False,
|
||||
) -> dict:
|
||||
"""Convert all values in a dictionary to lists of strings.
|
||||
|
||||
Args:
|
||||
dictionary (dict): Dictionary to convert
|
||||
strip_null (bool): Whether to strip null values
|
||||
strip_null_values (bool): Whether to strip null values
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with all values converted to lists of strings
|
||||
@@ -46,6 +104,55 @@ def dict_values_to_lists_strings(dictionary: dict, strip_null_values: bool = Fal
|
||||
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,
|
||||
@@ -78,80 +185,5 @@ def remove_markdown_sections(
|
||||
def version_callback(value: bool) -> None:
|
||||
"""Print version and exit."""
|
||||
if value:
|
||||
print(f"{__package__.split('.')[0]}: v{__version__}")
|
||||
console.print(f"{__package__.split('.')[0]}: v{__version__}")
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
def docstring_parameter(*sub: Any) -> Any:
|
||||
"""Decorator to replace variables within docstrings.
|
||||
|
||||
Args:
|
||||
sub (Any): Replacement variables
|
||||
|
||||
Usage:
|
||||
@docstring_parameter("foo", "bar")
|
||||
def foo():
|
||||
'''This is a {0} docstring with {1} variables.'''
|
||||
|
||||
"""
|
||||
|
||||
def dec(obj: Any) -> Any:
|
||||
"""Format object."""
|
||||
obj.__doc__ = obj.__doc__.format(*sub)
|
||||
return obj
|
||||
|
||||
return dec
|
||||
|
||||
|
||||
def clean_dictionary(dictionary: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Clean up a dictionary by markdown formatting from keys and values.
|
||||
|
||||
Args:
|
||||
dictionary (dict): Dictionary to clean
|
||||
|
||||
Returns:
|
||||
dict: Cleaned dictionary
|
||||
"""
|
||||
new_dict = {key.strip(): value for key, value in dictionary.items()}
|
||||
new_dict = {key.strip("*[]#"): value for key, value in new_dict.items()}
|
||||
for key, value in new_dict.items():
|
||||
new_dict[key] = [s.strip("*[]#") for s in value if isinstance(value, list)]
|
||||
|
||||
return new_dict
|
||||
|
||||
|
||||
def clear_screen() -> None: # pragma: no cover
|
||||
"""Clear the screen."""
|
||||
# for windows
|
||||
_ = system("cls") if name == "nt" else system("clear")
|
||||
|
||||
|
||||
def dict_contains(
|
||||
dictionary: dict[str, list[str]], key: str, value: str = None, is_regex: bool = False
|
||||
) -> bool:
|
||||
"""Check if a dictionary contains a key.
|
||||
|
||||
Args:
|
||||
dictionary (dict): Dictionary to check
|
||||
key (str): Key to check for
|
||||
value (str, optional): Value to check for. Defaults to None.
|
||||
is_regex (bool, optional): Whether the key is a regex. Defaults to False.
|
||||
|
||||
Returns:
|
||||
bool: Whether the dictionary contains the key
|
||||
"""
|
||||
if value is None:
|
||||
if is_regex:
|
||||
return any(re.search(key, str(_key)) for _key in dictionary)
|
||||
return key in dictionary
|
||||
|
||||
if is_regex:
|
||||
found_keys = []
|
||||
for _key in dictionary:
|
||||
if re.search(key, str(_key)):
|
||||
found_keys.append(
|
||||
any(re.search(value, _v) for _v in dictionary[_key]),
|
||||
)
|
||||
return any(found_keys)
|
||||
|
||||
return key in dictionary and value in dictionary[key]
|
||||
|
||||
@@ -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",
|
||||
@@ -70,7 +83,7 @@ def main(
|
||||
None, "--version", help="Print version and exit", callback=version_callback, is_eager=True
|
||||
),
|
||||
) -> None:
|
||||
r"""A script to make batch updates to metadata in an Obsidian vault. No changes are made to the Vault until they are explicitly committed.
|
||||
r"""Make batch updates to metadata in an Obsidian vault. No changes are made to the Vault until they are explicitly committed.
|
||||
|
||||
[bold] [/]
|
||||
[bold underline]It is strongly recommended that you back up your vault prior to committing changes.[/] This script makes changes directly to the markdown files in your vault. Once the changes are committed, there is no ability to recreate the original information unless you have a backup. Follow the instructions in the script to create a backup of your vault if needed. The author of this script is not responsible for any data loss that may occur. Use at your own risk.
|
||||
@@ -82,36 +95,60 @@ def main(
|
||||
[tan]Obsidian-metadata[/] provides a menu of sub-commands.
|
||||
|
||||
[bold underline]Vault Actions[/]
|
||||
Create or delete a backup of your vault.
|
||||
• Backup: Create a backup of the vault.
|
||||
• Delete Backup: Delete a backup of the vault.
|
||||
|
||||
[bold underline]Inspect Metadata[/]
|
||||
Inspect the metadata in your vault.
|
||||
• View all metadata in the vault
|
||||
• View all frontmatter
|
||||
• View all inline metadata
|
||||
• View all inline tags
|
||||
• Export all metadata to CSV or JSON file
|
||||
|
||||
[bold underline]Filter Notes in Scope[/]
|
||||
Limit the scope of notes to be processed with a regex.
|
||||
• Apply regex: Set a regex to limit scope
|
||||
• List notes in scope: List notes that will be processed.
|
||||
Limit the scope of notes to be processed with one or more filters.
|
||||
• Path filter (regex): Limit scope based on the path or filename
|
||||
• Metadata filter: Limit scope based on a key or key/value pair
|
||||
• Tag filter: Limit scope based on an in-text tag
|
||||
• List and clear filters: List all current filters and clear one or all
|
||||
• List notes in scope: List notes that will be processed.
|
||||
|
||||
[bold underline]Add Metadata[/]
|
||||
• Add metadata to the frontmatter
|
||||
• [dim]Add to inline metadata (Not yet implemented)[/]
|
||||
• [dim]Add to inline tag (Not yet implemented)[/]
|
||||
Add new metadata to your vault.
|
||||
• Add new metadata to the frontmatter
|
||||
• Add new inline metadata - Set `insert_location` in the config to
|
||||
control where the new metadata is inserted. (Default: Bottom)
|
||||
• Add new inline tag - Set `insert_location` in the config to
|
||||
control where the new tag is inserted. (Default: Bottom)
|
||||
|
||||
[bold underline]Rename Metadata[/]
|
||||
Rename either a key and all associated values, a specific value within a key. or an in-text tag.
|
||||
• Rename a key
|
||||
• Rename a value
|
||||
• rename an inline tag
|
||||
|
||||
[bold underline]Delete Metadata[/]
|
||||
Delete either a key and all associated values, or a specific value.
|
||||
• Delete a key and associated values
|
||||
• Delete a value from a key
|
||||
• Delete an inline tag
|
||||
|
||||
[bold underline]Transpose Metadata[/]
|
||||
Move metadata from inline to frontmatter or the reverse.
|
||||
|
||||
• 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
|
||||
|
||||
[bold underline]Review Changes[/]
|
||||
Prior to committing changes, review all changes that will be made.
|
||||
• View a diff of the changes that will be made
|
||||
|
||||
[bold underline]Commit Changes[/]
|
||||
Write the changes to disk. This step is not undoable.
|
||||
• Commit changes to the vault
|
||||
|
||||
"""
|
||||
@@ -134,7 +171,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:
|
||||
@@ -155,6 +192,15 @@ def main(
|
||||
vault_to_use = next(vault for vault in config.vaults if vault.name == vault_name)
|
||||
application = Application(dry_run=dry_run, config=vault_to_use)
|
||||
|
||||
if export_json is not None:
|
||||
path = Path(export_json).expanduser().resolve()
|
||||
application.noninteractive_export_json(path)
|
||||
raise typer.Exit(code=0)
|
||||
if export_csv is not None:
|
||||
path = Path(export_json).expanduser().resolve()
|
||||
application.noninteractive_export_csv(path)
|
||||
raise typer.Exit(code=0)
|
||||
|
||||
application.application_main()
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""Shared models."""
|
||||
from obsidian_metadata.models.enums import MetadataType # isort: skip
|
||||
from obsidian_metadata.models.enums import (
|
||||
InsertLocation,
|
||||
MetadataType,
|
||||
)
|
||||
|
||||
from obsidian_metadata.models.patterns import Patterns # isort: skip
|
||||
from obsidian_metadata.models.metadata import (
|
||||
Frontmatter,
|
||||
@@ -8,7 +12,7 @@ from obsidian_metadata.models.metadata import (
|
||||
VaultMetadata,
|
||||
)
|
||||
from obsidian_metadata.models.notes import Note
|
||||
from obsidian_metadata.models.vault import Vault
|
||||
from obsidian_metadata.models.vault import Vault, VaultFilter
|
||||
|
||||
from obsidian_metadata.models.application import Application # isort: skip
|
||||
|
||||
@@ -17,10 +21,12 @@ __all__ = [
|
||||
"Frontmatter",
|
||||
"InlineMetadata",
|
||||
"InlineTags",
|
||||
"InsertLocation",
|
||||
"LoggerManager",
|
||||
"MetadataType",
|
||||
"Note",
|
||||
"Patterns",
|
||||
"Vault",
|
||||
"VaultFilter",
|
||||
"VaultMetadata",
|
||||
]
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
"""Questions for the cli."""
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import questionary
|
||||
from rich import print
|
||||
from textwrap import dedent
|
||||
from obsidian_metadata._config import VaultConfig
|
||||
from obsidian_metadata._utils.alerts import logger as log
|
||||
from obsidian_metadata.models import Patterns, Vault
|
||||
from obsidian_metadata._utils import alerts
|
||||
from obsidian_metadata.models.questions import Questions
|
||||
from obsidian_metadata.models.enums import MetadataType
|
||||
import typer
|
||||
from rich import box
|
||||
from rich.table import Table
|
||||
|
||||
PATTERNS = Patterns()
|
||||
from obsidian_metadata._config import VaultConfig
|
||||
from obsidian_metadata._utils import alerts
|
||||
from obsidian_metadata._utils.console import console
|
||||
from obsidian_metadata.models import Vault, VaultFilter
|
||||
from obsidian_metadata.models.enums import MetadataType
|
||||
from obsidian_metadata.models.questions import Questions
|
||||
|
||||
|
||||
class Application:
|
||||
@@ -28,15 +29,28 @@ class Application:
|
||||
self.config = config
|
||||
self.dry_run = dry_run
|
||||
self.questions = Questions()
|
||||
self.filters: list[VaultFilter] = []
|
||||
|
||||
def _load_vault(self) -> None:
|
||||
"""Load the vault."""
|
||||
if len(self.filters) == 0:
|
||||
self.vault: Vault = Vault(config=self.config, dry_run=self.dry_run)
|
||||
else:
|
||||
self.vault = Vault(config=self.config, dry_run=self.dry_run, filters=self.filters)
|
||||
|
||||
alerts.success(
|
||||
f"Loaded {len(self.vault.notes_in_scope)} notes from {len(self.vault.all_notes)} total notes"
|
||||
)
|
||||
self.questions = Questions(vault=self.vault)
|
||||
|
||||
def application_main(self) -> None:
|
||||
"""Questions for the main application."""
|
||||
self.load_vault()
|
||||
self._load_vault()
|
||||
|
||||
while True:
|
||||
self.vault.info()
|
||||
|
||||
match self.questions.ask_application_main(): # noqa: E999
|
||||
match self.questions.ask_application_main():
|
||||
case "vault_actions":
|
||||
self.application_vault()
|
||||
case "inspect_metadata":
|
||||
@@ -49,95 +63,213 @@ class Application:
|
||||
self.application_rename_metadata()
|
||||
case "delete_metadata":
|
||||
self.application_delete_metadata()
|
||||
case "transpose_metadata":
|
||||
self.application_transpose_metadata()
|
||||
case "review_changes":
|
||||
self.review_changes()
|
||||
case "commit_changes":
|
||||
if self.commit_changes():
|
||||
break
|
||||
log.error("Commit failed. Please run with -vvv for more info.")
|
||||
break
|
||||
|
||||
self.commit_changes()
|
||||
case _:
|
||||
break
|
||||
|
||||
print("Done!")
|
||||
console.print("Done!")
|
||||
return
|
||||
|
||||
def application_add_metadata(self) -> None:
|
||||
"""Add metadata."""
|
||||
help_text = """
|
||||
USAGE | Add Metadata
|
||||
[dim]Add new metadata to your vault. Currently only supports
|
||||
adding to the frontmatter of a note.[/]
|
||||
"""
|
||||
print(dedent(help_text))
|
||||
alerts.usage(
|
||||
"Add new metadata to your vault. Currently only supports adding to the frontmatter of a note."
|
||||
)
|
||||
|
||||
area = self.questions.ask_area()
|
||||
match area:
|
||||
case MetadataType.FRONTMATTER:
|
||||
case MetadataType.FRONTMATTER | MetadataType.INLINE:
|
||||
key = self.questions.ask_new_key(question="Enter the key for the new metadata")
|
||||
if key is None:
|
||||
if key is None: # pragma: no cover
|
||||
return
|
||||
|
||||
value = self.questions.ask_new_value(
|
||||
question="Enter the value for the new metadata"
|
||||
)
|
||||
if value is None:
|
||||
if value is None: # pragma: no cover
|
||||
return
|
||||
|
||||
num_changed = self.vault.add_metadata(area, key, value)
|
||||
if num_changed == 0:
|
||||
alerts.warning(f"No notes were changed")
|
||||
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.INLINE:
|
||||
alerts.warning(f"Adding metadata to {area} is not supported yet")
|
||||
|
||||
case MetadataType.TAGS:
|
||||
alerts.warning(f"Adding metadata to {area} is not supported yet")
|
||||
tag = self.questions.ask_new_tag()
|
||||
if tag is None: # pragma: no cover
|
||||
return
|
||||
|
||||
case _:
|
||||
num_changed = self.vault.add_metadata(
|
||||
area=area, value=tag, location=self.vault.insert_location
|
||||
)
|
||||
|
||||
if num_changed == 0: # pragma: no cover
|
||||
alerts.warning("No notes were changed")
|
||||
return
|
||||
|
||||
alerts.success(f"Added metadata to {num_changed} notes")
|
||||
case _: # pragma: no cover
|
||||
return
|
||||
|
||||
def application_filter(self) -> None:
|
||||
"""Filter notes."""
|
||||
help_text = """
|
||||
USAGE | Filter Notes
|
||||
[dim]Enter a regex to filter notes by path. This allows you to
|
||||
specify a subset of notes to update. Leave empty to include
|
||||
all markdown files.[/]
|
||||
"""
|
||||
print(dedent(help_text))
|
||||
def application_delete_metadata(self) -> None:
|
||||
"""Delete metadata."""
|
||||
alerts.usage("Delete either a key and all associated values, or a specific value.")
|
||||
|
||||
choices = [
|
||||
{"name": "Apply regex filter", "value": "apply_filter"},
|
||||
{"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 = [
|
||||
{"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 = [
|
||||
{"name": "Apply new regex path filter", "value": "apply_path_filter"},
|
||||
{"name": "Apply new metadata filter", "value": "apply_metadata_filter"},
|
||||
{"name": "Apply new in-text tag filter", "value": "apply_tag_filter"},
|
||||
{"name": "List and clear filters", "value": "list_filters"},
|
||||
{"name": "List notes in scope", "value": "list_notes"},
|
||||
questionary.Separator(),
|
||||
{"name": "Back", "value": "back"},
|
||||
]
|
||||
while True:
|
||||
match self.questions.ask_selection(choices=choices, question="Select an action"):
|
||||
case "apply_filter":
|
||||
|
||||
path_filter = self.questions.ask_filter_path()
|
||||
if path_filter is None:
|
||||
case "apply_path_filter":
|
||||
path = self.questions.ask_filter_path()
|
||||
if path is None or path == "": # pragma: no cover
|
||||
return
|
||||
|
||||
if path_filter == "":
|
||||
path_filter = None
|
||||
self.filters.append(VaultFilter(path_filter=path))
|
||||
self._load_vault()
|
||||
|
||||
self.load_vault(path_filter=path_filter)
|
||||
case "apply_metadata_filter":
|
||||
key = self.questions.ask_existing_key()
|
||||
if key is None: # pragma: no cover
|
||||
return
|
||||
|
||||
total_notes = self.vault.num_notes() + self.vault.num_excluded_notes()
|
||||
|
||||
if path_filter is None:
|
||||
alerts.success(f"Loaded all {total_notes} total notes")
|
||||
questions2 = Questions(vault=self.vault, key=key)
|
||||
value = questions2.ask_existing_value(
|
||||
question="Enter the value for the metadata filter",
|
||||
)
|
||||
if value is None: # pragma: no cover
|
||||
return
|
||||
if value == "":
|
||||
self.filters.append(VaultFilter(key_filter=key))
|
||||
else:
|
||||
alerts.success(
|
||||
f"Loaded {self.vault.num_notes()} notes from {total_notes} total notes"
|
||||
)
|
||||
self.filters.append(VaultFilter(key_filter=key, value_filter=value))
|
||||
self._load_vault()
|
||||
|
||||
case "apply_tag_filter":
|
||||
tag = self.questions.ask_existing_inline_tag()
|
||||
if tag is None or tag == "":
|
||||
return
|
||||
|
||||
self.filters.append(VaultFilter(tag_filter=tag))
|
||||
self._load_vault()
|
||||
|
||||
case "list_filters":
|
||||
if len(self.filters) == 0:
|
||||
alerts.notice("No filters have been applied")
|
||||
return
|
||||
|
||||
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()
|
||||
@@ -147,32 +279,80 @@ class Application:
|
||||
|
||||
def application_inspect_metadata(self) -> None:
|
||||
"""View metadata."""
|
||||
help_text = """
|
||||
USAGE | View Metadata
|
||||
[dim]Inspect the metadata in your vault. Note, uncommitted
|
||||
changes will be reflected in these reports[/]
|
||||
"""
|
||||
print(dedent(help_text))
|
||||
alerts.usage(
|
||||
"Inspect the metadata in your vault. Note, uncommitted changes will be reflected in these reports"
|
||||
)
|
||||
|
||||
choices = [
|
||||
{"name": "View all 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": "Write all metadata to CSV", "value": "export_csv"},
|
||||
{"name": "Write all metadata to JSON file", "value": "export_json"},
|
||||
questionary.Separator(),
|
||||
{"name": "Back", "value": "back"},
|
||||
]
|
||||
while True:
|
||||
match self.questions.ask_selection(choices=choices, question="Select a vault action"):
|
||||
case "all_metadata":
|
||||
self.vault.metadata.print_metadata()
|
||||
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 "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"Metadata written to {path}")
|
||||
case "export_json":
|
||||
path = self.questions.ask_path(question="Enter a path for the JSON file")
|
||||
if path is None:
|
||||
return
|
||||
self.vault.export_metadata(path=path, export_format="json")
|
||||
alerts.success(f"Metadata written to {path}")
|
||||
case _:
|
||||
return
|
||||
|
||||
def application_transpose_metadata(self) -> None:
|
||||
"""Transpose metadata."""
|
||||
alerts.usage("Transpose metadata from frontmatter to inline or vice versa.")
|
||||
|
||||
choices = [
|
||||
{"name": "Transpose frontmatter to inline", "value": "frontmatter_to_inline"},
|
||||
{"name": "Transpose inline to frontmatter", "value": "inline_to_frontmatter"},
|
||||
]
|
||||
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 _: # pragma: no cover
|
||||
return
|
||||
|
||||
def application_vault(self) -> None:
|
||||
"""Vault actions."""
|
||||
help_text = """
|
||||
USAGE | Vault Actions
|
||||
[dim]Create or delete a backup of your vault.[/]
|
||||
"""
|
||||
print(dedent(help_text))
|
||||
alerts.usage("Create or delete a backup of your vault.")
|
||||
|
||||
choices = [
|
||||
{"name": "Backup vault", "value": "backup_vault"},
|
||||
@@ -190,60 +370,6 @@ class Application:
|
||||
case _:
|
||||
return
|
||||
|
||||
def application_delete_metadata(self) -> None:
|
||||
help_text = """
|
||||
USAGE | Delete Metadata
|
||||
[dim]Delete either a key and all associated values,
|
||||
or a specific value.[/]
|
||||
"""
|
||||
print(dedent(help_text))
|
||||
|
||||
choices = [
|
||||
{"name": "Delete key", "value": "delete_key"},
|
||||
{"name": "Delete value", "value": "delete_value"},
|
||||
{"name": "Delete inline tag", "value": "delete_inline_tag"},
|
||||
questionary.Separator(),
|
||||
{"name": "Back", "value": "back"},
|
||||
]
|
||||
match self.questions.ask_selection(
|
||||
choices=choices, question="Select a metadata type to delete"
|
||||
):
|
||||
case "delete_key":
|
||||
self.delete_key()
|
||||
case "delete_value":
|
||||
self.delete_value()
|
||||
case "delete_inline_tag":
|
||||
self.delete_inline_tag()
|
||||
case _:
|
||||
return
|
||||
|
||||
def application_rename_metadata(self) -> None:
|
||||
"""Rename metadata."""
|
||||
help_text = """
|
||||
USAGE | Rename Metadata
|
||||
[dim]Select the type of metadata to rename.[/]
|
||||
"""
|
||||
print(dedent(help_text))
|
||||
|
||||
choices = [
|
||||
{"name": "Rename key", "value": "rename_key"},
|
||||
{"name": "Rename value", "value": "rename_value"},
|
||||
{"name": "Rename inline tag", "value": "rename_inline_tag"},
|
||||
questionary.Separator(),
|
||||
{"name": "Back", "value": "back"},
|
||||
]
|
||||
match self.questions.ask_selection(
|
||||
choices=choices, question="Select a metadata type to rename"
|
||||
):
|
||||
case "rename_key":
|
||||
self.rename_key()
|
||||
case "rename_value":
|
||||
self.rename_value()
|
||||
case "rename_inline_tag":
|
||||
self.rename_inline_tag()
|
||||
case _:
|
||||
return
|
||||
|
||||
def commit_changes(self) -> bool:
|
||||
"""Write all changes to disk.
|
||||
|
||||
@@ -253,7 +379,7 @@ class Application:
|
||||
changed_notes = self.vault.get_changed_notes()
|
||||
|
||||
if len(changed_notes) == 0:
|
||||
print("\n")
|
||||
console.print("\n")
|
||||
alerts.notice("No changes to commit.\n")
|
||||
return False
|
||||
|
||||
@@ -264,12 +390,13 @@ class Application:
|
||||
self.vault.backup()
|
||||
|
||||
if questionary.confirm(f"Commit {len(changed_notes)} changed files to disk?").ask():
|
||||
self.vault.commit_changes()
|
||||
|
||||
self.vault.write()
|
||||
if not self.dry_run:
|
||||
alerts.success(f"{len(changed_notes)} changes committed to disk. Exiting")
|
||||
return True
|
||||
raise typer.Exit(0)
|
||||
|
||||
return False
|
||||
return True
|
||||
|
||||
def delete_inline_tag(self) -> None:
|
||||
"""Delete an inline tag."""
|
||||
@@ -277,7 +404,7 @@ class Application:
|
||||
|
||||
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")
|
||||
@@ -288,7 +415,7 @@ class Application:
|
||||
key_to_delete = self.questions.ask_existing_keys_regex(
|
||||
question="Regex for the key(s) you'd like to delete?"
|
||||
)
|
||||
if key_to_delete is None:
|
||||
if key_to_delete is None: # pragma: no cover
|
||||
return
|
||||
|
||||
num_changed = self.vault.delete_metadata(key_to_delete)
|
||||
@@ -305,12 +432,12 @@ class Application:
|
||||
def delete_value(self) -> None:
|
||||
"""Delete a value from the vault."""
|
||||
key = self.questions.ask_existing_key(question="Which key contains the value to delete?")
|
||||
if key is None:
|
||||
if key is None: # pragma: no cover
|
||||
return
|
||||
|
||||
questions2 = Questions(vault=self.vault, key=key)
|
||||
value = questions2.ask_existing_value_regex(question="Regex for the value to delete")
|
||||
if value is None:
|
||||
if value is None: # pragma: no cover
|
||||
return
|
||||
|
||||
num_changed = self.vault.delete_metadata(key, value)
|
||||
@@ -324,32 +451,33 @@ class Application:
|
||||
|
||||
return
|
||||
|
||||
def load_vault(self, path_filter: str = None) -> None:
|
||||
"""Load the vault.
|
||||
def noninteractive_export_csv(self, path: Path) -> None:
|
||||
"""Export the vault metadata to CSV."""
|
||||
self._load_vault()
|
||||
self.vault.export_metadata(export_format="json", path=str(path))
|
||||
alerts.success(f"Exported metadata to {path}")
|
||||
|
||||
Args:
|
||||
path_filter (str, optional): Regex to filter notes by path.
|
||||
"""
|
||||
self.vault: Vault = Vault(config=self.config, dry_run=self.dry_run, path_filter=path_filter)
|
||||
log.info(f"Indexed {self.vault.num_notes()} notes from {self.vault.vault_path}")
|
||||
self.questions = Questions(vault=self.vault)
|
||||
def noninteractive_export_json(self, path: Path) -> None:
|
||||
"""Export the vault metadata to JSON."""
|
||||
self._load_vault()
|
||||
self.vault.export_metadata(export_format="json", path=str(path))
|
||||
alerts.success(f"Exported metadata to {path}")
|
||||
|
||||
def rename_key(self) -> None:
|
||||
"""Renames a key in the vault."""
|
||||
|
||||
"""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:
|
||||
if original_key is None: # pragma: no cover
|
||||
return
|
||||
|
||||
new_key = self.questions.ask_new_key()
|
||||
if new_key is None:
|
||||
if new_key is None: # pragma: no cover
|
||||
return
|
||||
|
||||
num_changed = self.vault.rename_metadata(original_key, new_key)
|
||||
if num_changed == 0:
|
||||
alerts.warning(f"No notes were changed")
|
||||
alerts.warning("No notes were changed")
|
||||
return
|
||||
|
||||
alerts.success(
|
||||
@@ -358,18 +486,17 @@ class Application:
|
||||
|
||||
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:
|
||||
if original_tag is None: # pragma: no cover
|
||||
return
|
||||
|
||||
new_tag = self.questions.ask_new_tag("New tag")
|
||||
if new_tag is None:
|
||||
if new_tag is None: # pragma: no cover
|
||||
return
|
||||
|
||||
num_changed = self.vault.rename_inline_tag(original_tag, new_tag)
|
||||
if num_changed == 0:
|
||||
alerts.warning(f"No notes were changed")
|
||||
alerts.warning("No notes were changed")
|
||||
return
|
||||
|
||||
alerts.success(
|
||||
@@ -380,21 +507,21 @@ class Application:
|
||||
def rename_value(self) -> None:
|
||||
"""Rename a value in the vault."""
|
||||
key = self.questions.ask_existing_key(question="Which key contains the value to rename?")
|
||||
if key is None:
|
||||
if key is None: # pragma: no cover
|
||||
return
|
||||
|
||||
question_key = Questions(vault=self.vault, key=key)
|
||||
value = question_key.ask_existing_value(question="Which value would you like to rename?")
|
||||
if value is None:
|
||||
if value is None: # pragma: no cover
|
||||
return
|
||||
|
||||
new_value = question_key.ask_new_value()
|
||||
if new_value is None:
|
||||
if new_value is None: # pragma: no cover
|
||||
return
|
||||
|
||||
num_changes = self.vault.rename_metadata(key, value, new_value)
|
||||
if num_changes == 0:
|
||||
alerts.warning(f"No notes were changed")
|
||||
alerts.warning("No notes were changed")
|
||||
return
|
||||
|
||||
alerts.success(f"Renamed '{key}:{value}' to '{key}:{new_value}' in {num_changes} notes")
|
||||
@@ -407,14 +534,8 @@ 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] = []
|
||||
for n, note in enumerate(changed_notes, start=1):
|
||||
_selection = {
|
||||
"name": f"{n}: {note.note_path.relative_to(self.vault.vault_path)}",
|
||||
@@ -428,8 +549,81 @@ class Application:
|
||||
while True:
|
||||
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 transpose_metadata(self, begin: MetadataType, end: MetadataType) -> None: # noqa: PLR0911
|
||||
"""Transpose metadata from one format to another.
|
||||
|
||||
Args:
|
||||
begin: The format to transpose from.
|
||||
end: The format to transpose to.
|
||||
"""
|
||||
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"},
|
||||
{"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 num_changed == 0:
|
||||
alerts.warning("No notes were changed")
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
num_changed = self.vault.transpose_metadata(
|
||||
begin=begin,
|
||||
end=end,
|
||||
key=key,
|
||||
location=self.vault.insert_location,
|
||||
)
|
||||
|
||||
if num_changed == 0:
|
||||
alerts.warning("No notes were changed")
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
@@ -9,3 +9,19 @@ class MetadataType(Enum):
|
||||
FRONTMATTER = "Frontmatter"
|
||||
INLINE = "Inline Metadata"
|
||||
TAGS = "Inline Tags"
|
||||
KEYS = "Metadata Keys Only"
|
||||
ALL = "All Metadata"
|
||||
|
||||
|
||||
class InsertLocation(Enum):
|
||||
"""Location to add metadata to notes.
|
||||
|
||||
TOP: Directly after frontmatter.
|
||||
AFTER_TITLE: After a header following frontmatter.
|
||||
BOTTOM: The bottom of the note
|
||||
|
||||
"""
|
||||
|
||||
TOP = "Top"
|
||||
AFTER_TITLE = "After title"
|
||||
BOTTOM = "Bottom"
|
||||
|
||||
@@ -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,50 +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 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.
|
||||
@@ -81,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):
|
||||
@@ -99,37 +132,55 @@ class VaultMetadata:
|
||||
|
||||
return False
|
||||
|
||||
def print_keys(self) -> None:
|
||||
"""Print all metadata keys."""
|
||||
columns = Columns(
|
||||
sorted(self.dict.keys()),
|
||||
equal=True,
|
||||
expand=True,
|
||||
title="All metadata keys in Obsidian vault",
|
||||
)
|
||||
print(columns)
|
||||
def print_metadata(self, area: MetadataType) -> None:
|
||||
"""Print metadata to the terminal.
|
||||
|
||||
def print_metadata(self) -> None:
|
||||
"""Print all metadata."""
|
||||
table = Table(show_footer=False, show_lines=True)
|
||||
table.add_column("Keys")
|
||||
table.add_column("Values")
|
||||
for key, value in sorted(self.dict.items()):
|
||||
values: str | dict[str, list[str]] = (
|
||||
"\n".join(sorted(value)) if isinstance(value, list) else value
|
||||
Args:
|
||||
area (MetadataType): Type of metadata to print
|
||||
"""
|
||||
dict_to_print: dict[str, list[str]] = None
|
||||
list_to_print: list[str] = None
|
||||
match area:
|
||||
case MetadataType.INLINE:
|
||||
dict_to_print = self.inline_metadata.copy()
|
||||
header = "All inline metadata"
|
||||
case MetadataType.FRONTMATTER:
|
||||
dict_to_print = self.frontmatter.copy()
|
||||
header = "All frontmatter"
|
||||
case MetadataType.TAGS:
|
||||
list_to_print = []
|
||||
for tag in self.tags:
|
||||
list_to_print.append(f"#{tag}")
|
||||
header = "All inline tags"
|
||||
case MetadataType.KEYS:
|
||||
list_to_print = sorted(self.dict.keys())
|
||||
header = "All Keys"
|
||||
case MetadataType.ALL:
|
||||
dict_to_print = self.dict.copy()
|
||||
list_to_print = []
|
||||
for tag in self.tags:
|
||||
list_to_print.append(f"#{tag}")
|
||||
header = "All metadata"
|
||||
|
||||
if dict_to_print is not None:
|
||||
table = Table(title=header, show_footer=False, show_lines=True)
|
||||
table.add_column("Keys")
|
||||
table.add_column("Values")
|
||||
for key, value in sorted(dict_to_print.items()):
|
||||
values: str | dict[str, list[str]] = (
|
||||
"\n".join(sorted(value)) if isinstance(value, list) else value
|
||||
)
|
||||
table.add_row(f"[bold]{key}[/]", str(values))
|
||||
console.print(table)
|
||||
|
||||
if list_to_print is not None:
|
||||
columns = Columns(
|
||||
sorted(list_to_print),
|
||||
equal=True,
|
||||
expand=True,
|
||||
title=header if area != MetadataType.ALL else "All inline tags",
|
||||
)
|
||||
table.add_row(f"[bold]{key}[/]", str(values))
|
||||
Console().print(table)
|
||||
|
||||
def print_tags(self) -> None:
|
||||
"""Print all tags."""
|
||||
columns = Columns(
|
||||
sorted(self.dict["tags"]),
|
||||
equal=True,
|
||||
expand=True,
|
||||
title="All tags in Obsidian vault",
|
||||
)
|
||||
print(columns)
|
||||
console.print(columns)
|
||||
|
||||
def rename(self, key: str, value_1: str, value_2: str = None) -> bool:
|
||||
"""Replace a value in the frontmatter.
|
||||
@@ -160,9 +211,8 @@ class Frontmatter:
|
||||
"""Representation of frontmatter metadata."""
|
||||
|
||||
def __init__(self, file_content: str):
|
||||
|
||||
self.dict: dict[str, list[str]] = self._grab_note_frontmatter(file_content)
|
||||
self.dict_original: dict[str, list[str]] = self.dict.copy()
|
||||
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,24 @@ 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
|
||||
|
||||
for k in frontmatter:
|
||||
if frontmatter[k] is None:
|
||||
@@ -197,7 +251,7 @@ class Frontmatter:
|
||||
|
||||
return dict_values_to_lists_strings(frontmatter, strip_null_values=True)
|
||||
|
||||
def add(self, key: str, value: str | list[str] = None) -> bool:
|
||||
def add(self, key: str, value: str | list[str] = None) -> bool: # noqa: PLR0911
|
||||
"""Add a key and value to the frontmatter.
|
||||
|
||||
Args:
|
||||
@@ -310,7 +364,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
|
||||
@@ -336,9 +390,8 @@ class InlineMetadata:
|
||||
"""Representation of inline metadata in the form of `key:: value`."""
|
||||
|
||||
def __init__(self, file_content: str):
|
||||
|
||||
self.dict: dict[str, list[str]] = self._grab_inline_metadata(file_content)
|
||||
self.dict_original: dict[str, list[str]] = self.dict.copy()
|
||||
self.dict: dict[str, list[str]] = self.grab_inline_metadata(file_content)
|
||||
self.dict_original: dict[str, list[str]] = copy.deepcopy(self.dict)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
"""Representation of inline metadata.
|
||||
@@ -348,32 +401,8 @@ class InlineMetadata:
|
||||
"""
|
||||
return f"InlineMetadata(inline_metadata={self.dict})"
|
||||
|
||||
def _grab_inline_metadata(self, file_content: str) -> dict[str, list[str]]:
|
||||
"""Grab inline metadata from a note.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: Inline metadata from the note.
|
||||
"""
|
||||
content = remove_markdown_sections(
|
||||
file_content,
|
||||
strip_codeblocks=True,
|
||||
strip_inlinecode=True,
|
||||
strip_frontmatter=True,
|
||||
)
|
||||
all_results = PATTERNS.find_inline_metadata.findall(content)
|
||||
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
|
||||
|
||||
inline_metadata: dict[str, list[str]] = {}
|
||||
for (k, v) in stripped_null_values:
|
||||
if k in inline_metadata:
|
||||
inline_metadata[k].append(str(v))
|
||||
else:
|
||||
inline_metadata[k] = [str(v)]
|
||||
|
||||
return clean_dictionary(inline_metadata)
|
||||
|
||||
def add(self, key: str, value: str | list[str] = None) -> bool:
|
||||
"""Add a key and value to the frontmatter.
|
||||
def add(self, key: str, value: str | list[str] = None) -> bool: # noqa: PLR0911
|
||||
"""Add a key and value to the inline metadata.
|
||||
|
||||
Args:
|
||||
key (str): Key to add.
|
||||
@@ -382,8 +411,29 @@ class InlineMetadata:
|
||||
Returns:
|
||||
bool: True if the metadata was added
|
||||
"""
|
||||
# TODO: implement adding to inline metadata which requires knowing where in the note the metadata is to be added. In addition, unlike frontmatter, it is not possible to have multiple values for a key.
|
||||
pass
|
||||
if value is None:
|
||||
if key not in self.dict:
|
||||
self.dict[key] = []
|
||||
return True
|
||||
return False
|
||||
|
||||
if key not in self.dict:
|
||||
if isinstance(value, list):
|
||||
self.dict[key] = value
|
||||
return True
|
||||
|
||||
self.dict[key] = [value]
|
||||
return True
|
||||
|
||||
if key in self.dict and value not in self.dict[key]:
|
||||
if isinstance(value, list):
|
||||
self.dict[key].extend(value)
|
||||
return True
|
||||
|
||||
self.dict[key].append(value)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def contains(self, key: str, value: str = None, is_regex: bool = False) -> bool:
|
||||
"""Check if a key or value exists in the inline metadata.
|
||||
@@ -426,6 +476,30 @@ class InlineMetadata:
|
||||
|
||||
return False
|
||||
|
||||
def grab_inline_metadata(self, file_content: str) -> dict[str, list[str]]:
|
||||
"""Grab inline metadata from a note.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: Inline metadata from the note.
|
||||
"""
|
||||
content = remove_markdown_sections(
|
||||
file_content,
|
||||
strip_codeblocks=True,
|
||||
strip_inlinecode=True,
|
||||
strip_frontmatter=True,
|
||||
)
|
||||
all_results = PATTERNS.find_inline_metadata.findall(content)
|
||||
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
|
||||
|
||||
inline_metadata: dict[str, list[str]] = {}
|
||||
for k, v in stripped_null_values:
|
||||
if k in inline_metadata:
|
||||
inline_metadata[k].append(str(v))
|
||||
else:
|
||||
inline_metadata[k] = [str(v)]
|
||||
|
||||
return clean_dictionary(inline_metadata)
|
||||
|
||||
def has_changes(self) -> bool:
|
||||
"""Check if the metadata has changes.
|
||||
|
||||
@@ -462,7 +536,6 @@ class InlineTags:
|
||||
"""Representation of inline tags."""
|
||||
|
||||
def __init__(self, file_content: str):
|
||||
|
||||
self.metadata_key = INLINE_TAG_KEY
|
||||
self.list: list[str] = self._grab_inline_tags(file_content)
|
||||
self.list_original: list[str] = self.list.copy()
|
||||
@@ -494,6 +567,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.
|
||||
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
"""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.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from obsidian_metadata._utils import alerts
|
||||
from obsidian_metadata._utils.alerts import logger as log
|
||||
from obsidian_metadata._utils.console import console
|
||||
from obsidian_metadata.models import (
|
||||
Frontmatter,
|
||||
InlineMetadata,
|
||||
InlineTags,
|
||||
InsertLocation,
|
||||
MetadataType,
|
||||
Patterns,
|
||||
)
|
||||
@@ -51,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
|
||||
@@ -64,101 +71,87 @@ class Note:
|
||||
yield "inline_tags", self.inline_tags
|
||||
yield "inline_metadata", self.inline_metadata
|
||||
|
||||
def _delete_inline_metadata(self, key: str, value: str = None) -> None:
|
||||
"""Deletes an inline metadata key/value pair from the text of the note. This method does not remove the key/value from the metadata attribute of the note.
|
||||
|
||||
Args:
|
||||
key (str): Key to delete.
|
||||
value (str, optional): Value to delete.
|
||||
"""
|
||||
all_results = PATTERNS.find_inline_metadata.findall(self.file_content)
|
||||
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
|
||||
|
||||
for (_k, _v) in stripped_null_values:
|
||||
if re.search(key, _k):
|
||||
if value is None:
|
||||
_k = re.escape(_k)
|
||||
_v = re.escape(_v)
|
||||
self.sub(rf"\[?{_k}:: ?{_v}]?", "", is_regex=True)
|
||||
return
|
||||
|
||||
if re.search(value, _v):
|
||||
_k = re.escape(_k)
|
||||
_v = re.escape(_v)
|
||||
self.sub(rf"({_k}::) ?{_v}", r"\1", is_regex=True)
|
||||
|
||||
def _rename_inline_metadata(self, key: str, value_1: str, value_2: str = None) -> None:
|
||||
"""Replaces the inline metadata in the note with the current inline metadata object.
|
||||
|
||||
Args:
|
||||
key (str): Key to rename.
|
||||
value_1 (str): Value to replace OR new key name (if value_2 is None).
|
||||
value_2 (str, optional): New value.
|
||||
|
||||
"""
|
||||
all_results = PATTERNS.find_inline_metadata.findall(self.file_content)
|
||||
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
|
||||
|
||||
for (_k, _v) in stripped_null_values:
|
||||
if re.search(key, _k):
|
||||
if value_2 is None:
|
||||
if re.search(rf"{key}[^\w\d_-]+", _k):
|
||||
key_text = re.split(r"[^\w\d_-]+$", _k)[0]
|
||||
key_markdown = re.split(r"^[\w\d_-]+", _k)[1]
|
||||
self.sub(
|
||||
rf"{key_text}{key_markdown}::",
|
||||
rf"{value_1}{key_markdown}::",
|
||||
)
|
||||
else:
|
||||
self.sub(f"{_k}::", f"{value_1}::")
|
||||
else:
|
||||
if re.search(key, _k) and re.search(value_1, _v):
|
||||
_k = re.escape(_k)
|
||||
_v = re.escape(_v)
|
||||
self.sub(f"{_k}:: ?{_v}", f"{_k}:: {value_2}", is_regex=True)
|
||||
|
||||
def add_metadata(self, area: MetadataType, key: str, value: str | list[str] = None) -> bool:
|
||||
"""Adds metadata to the note.
|
||||
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:
|
||||
area (MetadataType): Area to add metadata to.
|
||||
key (str): Key to add.
|
||||
key (str, optional): Key to add
|
||||
location (InsertLocation, optional): Location to add inline metadata and tags.
|
||||
value (str, optional): Value to add.
|
||||
|
||||
Returns:
|
||||
bool: Whether the metadata was added.
|
||||
"""
|
||||
if area is MetadataType.FRONTMATTER and self.frontmatter.add(key, value):
|
||||
self.replace_frontmatter()
|
||||
return True
|
||||
match area:
|
||||
case MetadataType.FRONTMATTER if self.frontmatter.add(key, value):
|
||||
self.write_frontmatter()
|
||||
return True
|
||||
|
||||
if area is MetadataType.INLINE:
|
||||
# TODO: implement adding to inline metadata
|
||||
pass
|
||||
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
|
||||
|
||||
if area is MetadataType.TAGS:
|
||||
# TODO: implement adding to intext tags
|
||||
pass
|
||||
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 append(self, string_to_append: str, allow_multiple: bool = False) -> None:
|
||||
"""Appends a string to the end of a note.
|
||||
def commit(self, path: Path = None) -> None:
|
||||
"""Write the note's new content to disk. This is a destructive action.
|
||||
|
||||
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}"
|
||||
path (Path): Path to write the note to. Defaults to the note's path.
|
||||
|
||||
def commit_changes(self) -> None:
|
||||
"""Commits changes to the note to disk."""
|
||||
# TODO: rewrite frontmatter if it has changed
|
||||
pass
|
||||
Raises:
|
||||
typer.Exit: If the note's path is not found.
|
||||
"""
|
||||
p = self.note_path if path is None else path
|
||||
if self.dry_run:
|
||||
log.trace(f"DRY RUN: Writing note {p} to disk")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(p, "w") as f:
|
||||
log.trace(f"Writing note {p} to disk")
|
||||
f.write(self.file_content)
|
||||
except FileNotFoundError as e:
|
||||
alerts.error(f"Note {p} not found. Exiting")
|
||||
raise typer.Exit(code=1) from e
|
||||
|
||||
def contains_inline_tag(self, tag: str, is_regex: bool = False) -> bool:
|
||||
"""Check if a note contains the specified inline tag.
|
||||
@@ -173,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.
|
||||
@@ -198,7 +191,7 @@ class Note:
|
||||
return False
|
||||
|
||||
def delete_inline_tag(self, tag: str) -> bool:
|
||||
"""Deletes an inline tag from the `inline_tags` attribute AND removes the tag from the text of the note if it exists.
|
||||
"""Delete an inline tag from the `inline_tags` attribute AND removes the tag from the text of the note if it exists.
|
||||
|
||||
Args:
|
||||
tag (str): Tag to delete.
|
||||
@@ -219,41 +212,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.
|
||||
@@ -272,12 +266,8 @@ 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()
|
||||
|
||||
@@ -291,32 +281,14 @@ class Note:
|
||||
elif line.startswith("-"):
|
||||
table.add_row(line, style="red")
|
||||
|
||||
Console().print(table)
|
||||
console.print(table)
|
||||
|
||||
def replace_frontmatter(self, sort_keys: bool = False) -> None:
|
||||
"""Replaces the frontmatter in the note with the current frontmatter object."""
|
||||
try:
|
||||
current_frontmatter = PATTERNS.frontmatt_block_with_separators.search(
|
||||
self.file_content
|
||||
).group("frontmatter")
|
||||
except AttributeError:
|
||||
current_frontmatter = None
|
||||
|
||||
if current_frontmatter is None and self.frontmatter.dict == {}:
|
||||
return
|
||||
|
||||
new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys)
|
||||
new_frontmatter = f"---\n{new_frontmatter}---\n"
|
||||
|
||||
if current_frontmatter is None:
|
||||
self.file_content = new_frontmatter + self.file_content
|
||||
return
|
||||
|
||||
current_frontmatter = re.escape(current_frontmatter)
|
||||
self.sub(current_frontmatter, new_frontmatter, is_regex=True)
|
||||
def 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.
|
||||
@@ -336,9 +308,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.
|
||||
@@ -351,17 +323,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:
|
||||
@@ -382,21 +354,245 @@ class Note:
|
||||
|
||||
self.file_content = re.sub(pattern, replacement, self.file_content, re.MULTILINE)
|
||||
|
||||
def write(self, path: Path = None) -> None:
|
||||
"""Writes the note's content to disk.
|
||||
def transpose_metadata( # noqa: C901, PLR0912, PLR0911, PLR0913
|
||||
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:
|
||||
path (Path): Path to write the note to. Defaults to the note's path.
|
||||
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.
|
||||
|
||||
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 (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.
|
||||
"""
|
||||
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)
|
||||
return True
|
||||
|
||||
if 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:
|
||||
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
|
||||
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 False
|
||||
|
||||
new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys)
|
||||
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 True
|
||||
|
||||
current_frontmatter = f"{re.escape(current_frontmatter)}\n?"
|
||||
self.sub(current_frontmatter, new_frontmatter, is_regex=True)
|
||||
return True
|
||||
|
||||
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:
|
||||
location (InsertLocation): Where to insert the metadata.
|
||||
|
||||
Returns:
|
||||
bool: Whether the note was updated.
|
||||
"""
|
||||
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"
|
||||
|
||||
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 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 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}")
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Regexes for parsing frontmatter and note content."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Pattern
|
||||
|
||||
import regex as re
|
||||
from regex import Pattern
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -11,8 +12,9 @@ class Patterns:
|
||||
|
||||
find_inline_tags: Pattern[str] = re.compile(
|
||||
r"""
|
||||
(?:^|[ \|_,;:\*\(\)\[\]\\\.]) # Before tag is start of line or separator
|
||||
\#([^ \|,;:\*\(\)\[\]\\\.\n#&]+) # Match tag until separator or end of line
|
||||
(?:^|[ \|_,;:\*\)\[\]\\\.]|(?<!\])\() # Before tag is start of line or separator
|
||||
(?<!\/\/[\w\d_\.\(\)\/&_-]+) # Before tag is not a link
|
||||
\#([^ \|,;:\*\(\)\[\]\\\.\n#&]+) # Match tag until separator or end of line
|
||||
""",
|
||||
re.MULTILINE | re.X,
|
||||
)
|
||||
@@ -20,23 +22,41 @@ class Patterns:
|
||||
find_inline_metadata: Pattern[str] = re.compile(
|
||||
r""" # First look for in-text key values
|
||||
(?:^\[| \[) # Find key with starting bracket
|
||||
([-_\w\d\/\*\u263a-\U0001f645]+?)::[ ]? # Find key
|
||||
([-_\w\d\/\*\u263a-\U0001f999]+?)::[ ]? # Find key
|
||||
(.*?)\] # Find value until closing bracket
|
||||
| # Else look for key values at start of line
|
||||
(?:^|[^ \w\d]+| \[) # Any non-word or non-digit character
|
||||
([-_\w\d\/\*\u263a-\U0001f645]+?)::(?!\n)(?:[ ](?!\n))? # Capture the key if not a new line
|
||||
([-_\w\d\/\*\u263a-\U0001f9995]+?)::(?!\n)(?:[ ](?!\n))? # Capture the key if not a new line
|
||||
(.*?)$ # Capture the value
|
||||
""",
|
||||
re.X | re.MULTILINE,
|
||||
)
|
||||
|
||||
frontmatt_block_with_separators: Pattern[str] = re.compile(
|
||||
r"^\s*(?P<frontmatter>---.*?---)", flags=re.DOTALL
|
||||
)
|
||||
frontmatt_block_no_separators: Pattern[str] = re.compile(
|
||||
frontmatter_block: Pattern[str] = re.compile(r"^\s*(?P<frontmatter>---.*?---)", flags=re.DOTALL)
|
||||
frontmatt_block_strip_separators: Pattern[str] = re.compile(
|
||||
r"^\s*---(?P<frontmatter>.*?)---", flags=re.DOTALL
|
||||
)
|
||||
# This pattern will return a tuple of 4 values, two will be empty and will need to be stripped before processing further
|
||||
|
||||
validate_key_text: Pattern[str] = re.compile(r"[^-_\w\d\/\*\u263a-\U0001f645]")
|
||||
top_with_header: Pattern[str] = re.compile(
|
||||
r"""^\s* # Start of note
|
||||
(?P<top> # Capture the top of the note
|
||||
(---.*?---)? # Frontmatter, if it exists
|
||||
\s* # Any whitespace
|
||||
( # Full header, if it exists
|
||||
\#+[ ] # Match start of any header level
|
||||
( # Text of header
|
||||
[\w\d]+ # Word or digit
|
||||
| # Or
|
||||
[\[\]\(\)\+\{\}\"'\-\.\/\*\$\| ]+ # Special characters
|
||||
| # Or
|
||||
[\u263a-\U0001f999]+ # Emoji
|
||||
)+ # End of header text
|
||||
)? # End of full header
|
||||
) # End capture group
|
||||
""",
|
||||
flags=re.DOTALL | re.X,
|
||||
)
|
||||
|
||||
validate_key_text: Pattern[str] = re.compile(r"[^-_\w\d\/\*\u263a-\U0001f999]")
|
||||
validate_tag_text: Pattern[str] = re.compile(r"[ \|,;:\*\(\)\[\]\\\.\n#&]")
|
||||
|
||||
@@ -12,12 +12,21 @@ from typing import Any
|
||||
import questionary
|
||||
import typer
|
||||
|
||||
from obsidian_metadata.models.enums import MetadataType
|
||||
from obsidian_metadata.models.enums import InsertLocation, MetadataType
|
||||
from obsidian_metadata.models.patterns import Patterns
|
||||
from obsidian_metadata.models.vault import Vault
|
||||
|
||||
PATTERNS = Patterns()
|
||||
|
||||
# Reset the default style of the questionary prompts qmark
|
||||
questionary.prompts.checkbox.DEFAULT_STYLE = questionary.Style([("qmark", "")])
|
||||
questionary.prompts.common.DEFAULT_STYLE = questionary.Style([("qmark", "")])
|
||||
questionary.prompts.confirm.DEFAULT_STYLE = questionary.Style([("qmark", "")])
|
||||
questionary.prompts.confirm.DEFAULT_STYLE = questionary.Style([("qmark", "")])
|
||||
questionary.prompts.path.DEFAULT_STYLE = questionary.Style([("qmark", "")])
|
||||
questionary.prompts.select.DEFAULT_STYLE = questionary.Style([("qmark", "")])
|
||||
questionary.prompts.text.DEFAULT_STYLE = questionary.Style([("qmark", "")])
|
||||
|
||||
|
||||
class Questions:
|
||||
"""Class for asking questions to the user and validating responses with questionary."""
|
||||
@@ -41,7 +50,7 @@ class Questions:
|
||||
|
||||
@staticmethod
|
||||
def _validate_valid_dir(path: str) -> bool | str:
|
||||
"""Validates a valid directory.
|
||||
"""Validate a valid directory.
|
||||
|
||||
Returns:
|
||||
bool | str: True if the path is valid, otherwise a string with the error message.
|
||||
@@ -64,11 +73,12 @@ class Questions:
|
||||
"""
|
||||
self.style = questionary.Style(
|
||||
[
|
||||
("qmark", "fg:#808080 bold"),
|
||||
("qmark", "bold"),
|
||||
("question", "bold"),
|
||||
("separator", "fg:#808080"),
|
||||
("answer", "fg:#FF9D00 bold"),
|
||||
("instruction", "fg:#808080"),
|
||||
("highlighted", "fg:#c0c0c0 bold reverse"),
|
||||
("highlighted", "bold underline"),
|
||||
("text", ""),
|
||||
("pointer", "bold"),
|
||||
]
|
||||
@@ -77,7 +87,7 @@ class Questions:
|
||||
self.key = key
|
||||
|
||||
def _validate_existing_inline_tag(self, text: str) -> bool | str:
|
||||
"""Validates an existing inline tag.
|
||||
"""Validate an existing inline tag.
|
||||
|
||||
Returns:
|
||||
bool | str: True if the tag is valid, otherwise a string with the error message.
|
||||
@@ -85,13 +95,13 @@ class Questions:
|
||||
if len(text) < 1:
|
||||
return "Tag cannot be empty"
|
||||
|
||||
if not self.vault.contains_inline_tag(text):
|
||||
if not self.vault.metadata.contains(area=MetadataType.TAGS, value=text):
|
||||
return f"'{text}' does not exist as a tag in the vault"
|
||||
|
||||
return True
|
||||
|
||||
def _validate_key_exists(self, text: str) -> bool | str:
|
||||
"""Validates a valid key.
|
||||
"""Validate a valid key.
|
||||
|
||||
Returns:
|
||||
bool | str: True if the key is valid, otherwise a string with the error message.
|
||||
@@ -99,13 +109,13 @@ class Questions:
|
||||
if len(text) < 1:
|
||||
return "Key cannot be empty"
|
||||
|
||||
if not self.vault.metadata.contains(text):
|
||||
if not self.vault.metadata.contains(area=MetadataType.KEYS, key=text):
|
||||
return f"'{text}' does not exist as a key in the vault"
|
||||
|
||||
return True
|
||||
|
||||
def _validate_key_exists_regex(self, text: str) -> bool | str:
|
||||
"""Validates a valid key.
|
||||
"""Validate a valid key.
|
||||
|
||||
Returns:
|
||||
bool | str: True if the key is valid, otherwise a string with the error message.
|
||||
@@ -118,7 +128,7 @@ class Questions:
|
||||
except re.error as error:
|
||||
return f"Invalid regex: {error}"
|
||||
|
||||
if not self.vault.metadata.contains(text, is_regex=True):
|
||||
if not self.vault.metadata.contains(area=MetadataType.KEYS, key=text, is_regex=True):
|
||||
return f"'{text}' does not exist as a key in the vault"
|
||||
|
||||
return True
|
||||
@@ -169,13 +179,29 @@ class Questions:
|
||||
if len(text) < 1:
|
||||
return "Value cannot be empty"
|
||||
|
||||
if self.key is not None and self.vault.metadata.contains(self.key, text):
|
||||
if self.key is not None and self.vault.metadata.contains(
|
||||
area=MetadataType.ALL, key=self.key, value=text
|
||||
):
|
||||
return f"{self.key}:{text} already exists"
|
||||
|
||||
return True
|
||||
|
||||
def _validate_number(self, text: str) -> bool | str:
|
||||
"""Validate a number.
|
||||
|
||||
Args:
|
||||
text (str): The number to validate.
|
||||
|
||||
Returns:
|
||||
bool | str: True if the number is valid, otherwise a string with the error message.
|
||||
"""
|
||||
if not text.isdigit():
|
||||
return "Must be an integer"
|
||||
|
||||
return True
|
||||
|
||||
def _validate_valid_vault_regex(self, text: str) -> bool | str:
|
||||
"""Validates a valid regex.
|
||||
"""Validate a valid regex.
|
||||
|
||||
Returns:
|
||||
bool | str: True if the regex is valid, otherwise a string with the error message.
|
||||
@@ -202,10 +228,12 @@ class Questions:
|
||||
Returns:
|
||||
bool | str: True if the value is valid, otherwise a string with the error message.
|
||||
"""
|
||||
if len(text) < 1:
|
||||
return "Value cannot be empty"
|
||||
if len(text) == 0:
|
||||
return True
|
||||
|
||||
if self.key is not None and not self.vault.metadata.contains(self.key, text):
|
||||
if self.key is not None and not self.vault.metadata.contains(
|
||||
area=MetadataType.ALL, key=self.key, value=text
|
||||
):
|
||||
return f"{self.key}:{text} does not exist"
|
||||
|
||||
return True
|
||||
@@ -227,11 +255,45 @@ class Questions:
|
||||
except re.error as error:
|
||||
return f"Invalid regex: {error}"
|
||||
|
||||
if self.key is not None and not self.vault.metadata.contains(self.key, text, is_regex=True):
|
||||
if self.key is not None and not self.vault.metadata.contains(
|
||||
area=MetadataType.ALL, key=self.key, value=text, is_regex=True
|
||||
):
|
||||
return f"No values in {self.key} match regex: {text}"
|
||||
|
||||
return True
|
||||
|
||||
def ask_application_main(self) -> str: # pragma: no cover
|
||||
"""List for the main application interface.
|
||||
|
||||
Args:
|
||||
style (questionary.Style): The style to use for the question.
|
||||
|
||||
Returns:
|
||||
str: The selected application.
|
||||
"""
|
||||
return questionary.select(
|
||||
"What do you want to do?",
|
||||
choices=[
|
||||
questionary.Separator("-------------------------------"),
|
||||
{"name": "Vault Actions", "value": "vault_actions"},
|
||||
{"name": "Inspect Metadata", "value": "inspect_metadata"},
|
||||
{"name": "Filter Notes in Scope", "value": "filter_notes"},
|
||||
questionary.Separator("-------------------------------"),
|
||||
{"name": "Add Metadata", "value": "add_metadata"},
|
||||
{"name": "Delete Metadata", "value": "delete_metadata"},
|
||||
{"name": "Rename Metadata", "value": "rename_metadata"},
|
||||
{"name": "Transpose Metadata", "value": "transpose_metadata"},
|
||||
questionary.Separator("-------------------------------"),
|
||||
{"name": "Review Changes", "value": "review_changes"},
|
||||
{"name": "Commit Changes", "value": "commit_changes"},
|
||||
questionary.Separator("-------------------------------"),
|
||||
{"name": "Quit", "value": "abort"},
|
||||
],
|
||||
use_shortcuts=False,
|
||||
style=self.style,
|
||||
qmark="INPUT |",
|
||||
).ask()
|
||||
|
||||
def ask_area(self) -> MetadataType | str: # pragma: no cover
|
||||
"""Ask the user for the metadata area to work on.
|
||||
|
||||
@@ -347,34 +409,22 @@ class Questions:
|
||||
qmark="INPUT |",
|
||||
).ask()
|
||||
|
||||
def ask_application_main(self) -> str: # pragma: no cover
|
||||
"""Selectable list for the main application interface.
|
||||
|
||||
Args:
|
||||
style (questionary.Style): The style to use for the question.
|
||||
def ask_metadata_location(
|
||||
self, question: str = "Where in a note should we add metadata"
|
||||
) -> InsertLocation: # pragma: no cover
|
||||
"""Ask the user for the location within a note to place new metadata.
|
||||
|
||||
Returns:
|
||||
str: The selected application.
|
||||
InsertLocation: The location within a note to place new metadata.
|
||||
"""
|
||||
return questionary.select(
|
||||
"What do you want to do?",
|
||||
choices=[
|
||||
{"name": "Vault Actions", "value": "vault_actions"},
|
||||
{"name": "Inspect Metadata", "value": "inspect_metadata"},
|
||||
{"name": "Filter Notes in Scope", "value": "filter_notes"},
|
||||
{"name": "Add Metadata", "value": "add_metadata"},
|
||||
{"name": "Rename Metadata", "value": "rename_metadata"},
|
||||
{"name": "Delete Metadata", "value": "delete_metadata"},
|
||||
questionary.Separator("-------------------------------"),
|
||||
{"name": "Review Changes", "value": "review_changes"},
|
||||
{"name": "Commit Changes", "value": "commit_changes"},
|
||||
questionary.Separator("-------------------------------"),
|
||||
{"name": "Quit", "value": "abort"},
|
||||
],
|
||||
use_shortcuts=False,
|
||||
style=self.style,
|
||||
qmark="INPUT |",
|
||||
).ask()
|
||||
choices = []
|
||||
for metadata_location in InsertLocation:
|
||||
choices.append({"name": metadata_location.value, "value": metadata_location})
|
||||
|
||||
return self.ask_selection(
|
||||
choices=choices,
|
||||
question="Select the location for the metadata",
|
||||
)
|
||||
|
||||
def ask_new_key(self, question: str = "New key name") -> str: # pragma: no cover
|
||||
"""Ask the user for a new metadata key.
|
||||
@@ -389,8 +439,12 @@ class Questions:
|
||||
question, validate=self._validate_new_key, style=self.style, qmark="INPUT |"
|
||||
).ask()
|
||||
|
||||
def ask_new_tag(self, question: str = "New tag name") -> str: # pragma: no cover
|
||||
"""Ask the user for a new inline tag."""
|
||||
def ask_new_tag(self, question: str = "Enter a new tag") -> str: # pragma: no cover
|
||||
"""Ask the user for a new tag.
|
||||
|
||||
Args:
|
||||
question (str, optional): The question to ask. Defaults to "Enter a new tag".
|
||||
"""
|
||||
return questionary.text(
|
||||
question, validate=self._validate_new_tag, style=self.style, qmark="INPUT |"
|
||||
).ask()
|
||||
@@ -408,6 +462,30 @@ class Questions:
|
||||
question, validate=self._validate_new_value, style=self.style, qmark="INPUT |"
|
||||
).ask()
|
||||
|
||||
def ask_number(self, question: str = "Enter a number") -> int: # pragma: no cover
|
||||
"""Ask the user for a number.
|
||||
|
||||
Args:
|
||||
question (str, optional): The question to ask. Defaults to "Enter a number".
|
||||
|
||||
Returns:
|
||||
int: A number.
|
||||
"""
|
||||
return questionary.text(
|
||||
question, validate=self._validate_number, style=self.style, qmark="INPUT |"
|
||||
).ask()
|
||||
|
||||
def ask_path(self, question: str = "Enter a path") -> str: # pragma: no cover
|
||||
"""Ask the user for a path.
|
||||
|
||||
Args:
|
||||
question (str, optional): The question to ask. Defaults to "Enter a path".
|
||||
|
||||
Returns:
|
||||
str: A path.
|
||||
"""
|
||||
return questionary.path(question, style=self.style, qmark="INPUT |").ask()
|
||||
|
||||
def ask_selection(
|
||||
self, choices: list[Any], question: str = "Select an option"
|
||||
) -> Any: # pragma: no cover
|
||||
@@ -420,6 +498,7 @@ class Questions:
|
||||
Returns:
|
||||
any: The selected item value.
|
||||
"""
|
||||
choices.insert(0, questionary.Separator())
|
||||
return questionary.select(
|
||||
question,
|
||||
choices=choices,
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
"""Obsidian vault representation."""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import rich.repr
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
from rich.prompt import Confirm
|
||||
from rich.table import Table
|
||||
|
||||
from obsidian_metadata._config import 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 MetadataType, 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
|
||||
@@ -28,8 +41,16 @@ class Vault:
|
||||
notes (list[Note]): List of all notes in the vault.
|
||||
"""
|
||||
|
||||
def __init__(self, config: VaultConfig, dry_run: bool = False, path_filter: str = None):
|
||||
def __init__(
|
||||
self,
|
||||
config: VaultConfig,
|
||||
dry_run: bool = False,
|
||||
filters: list[VaultFilter] = [],
|
||||
):
|
||||
self.config = config.config
|
||||
self.vault_path: Path = config.path
|
||||
self.name = self.vault_path.name
|
||||
self.insert_location: InsertLocation = self._find_insert_location()
|
||||
self.dry_run: bool = dry_run
|
||||
self.backup_path: Path = self.vault_path.parent / f"{self.vault_path.name}.bak"
|
||||
self.exclude_paths: list[Path] = []
|
||||
@@ -37,8 +58,8 @@ class Vault:
|
||||
for p in config.exclude_paths:
|
||||
self.exclude_paths.append(Path(self.vault_path / p))
|
||||
|
||||
self.path_filter = path_filter
|
||||
self.note_paths = self._find_markdown_notes(path_filter)
|
||||
self.filters = filters
|
||||
self.all_note_paths = self._find_markdown_notes()
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
@@ -46,9 +67,10 @@ class Vault:
|
||||
transient=True,
|
||||
) as progress:
|
||||
progress.add_task(description="Processing notes...", total=None)
|
||||
self.notes: list[Note] = [
|
||||
Note(note_path=p, dry_run=self.dry_run) for p in self.note_paths
|
||||
self.all_notes: list[Note] = [
|
||||
Note(note_path=p, dry_run=self.dry_run) for p in self.all_note_paths
|
||||
]
|
||||
self.notes_in_scope = self._filter_notes()
|
||||
|
||||
self._rebuild_vault_metadata()
|
||||
|
||||
@@ -57,33 +79,90 @@ class Vault:
|
||||
yield "vault_path", self.vault_path
|
||||
yield "dry_run", self.dry_run
|
||||
yield "backup_path", self.backup_path
|
||||
yield "num_notes", self.num_notes()
|
||||
yield "num_notes", len(self.all_notes)
|
||||
yield "num_notes_in_scope", len(self.notes_in_scope)
|
||||
yield "exclude_paths", self.exclude_paths
|
||||
yield "insert_location", self.insert_location
|
||||
|
||||
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)))
|
||||
]
|
||||
|
||||
return notes_list
|
||||
|
||||
def _rebuild_vault_metadata(self) -> None:
|
||||
"""Rebuild vault metadata."""
|
||||
self.metadata = VaultMetadata()
|
||||
@@ -93,26 +172,43 @@ class Vault:
|
||||
transient=True,
|
||||
) as progress:
|
||||
progress.add_task(description="Processing notes...", total=None)
|
||||
for _note in self.notes:
|
||||
self.metadata.add_metadata(_note.frontmatter.dict)
|
||||
self.metadata.add_metadata(_note.inline_metadata.dict)
|
||||
self.metadata.add_metadata({_note.inline_tags.metadata_key: _note.inline_tags.list})
|
||||
for _note in self.notes_in_scope:
|
||||
self.metadata.index_metadata(
|
||||
area=MetadataType.FRONTMATTER, metadata=_note.frontmatter.dict
|
||||
)
|
||||
self.metadata.index_metadata(
|
||||
area=MetadataType.INLINE, metadata=_note.inline_metadata.dict
|
||||
)
|
||||
self.metadata.index_metadata(
|
||||
area=MetadataType.TAGS,
|
||||
metadata=_note.inline_tags.list,
|
||||
)
|
||||
|
||||
def add_metadata(self, area: MetadataType, key: str, value: str | list[str] = None) -> int:
|
||||
"""Add metadata to all notes in the vault.
|
||||
def add_metadata(
|
||||
self,
|
||||
area: MetadataType,
|
||||
key: str = None,
|
||||
value: str | list[str] = None,
|
||||
location: InsertLocation = None,
|
||||
) -> int:
|
||||
"""Add metadata to all notes in the vault which do not already contain it.
|
||||
|
||||
Args:
|
||||
area (MetadataType): Area of metadata to add to.
|
||||
key (str): Key to add.
|
||||
value (str|list, optional): Value to add.
|
||||
location (InsertLocation, optional): Location to insert metadata. (Defaults to `vault.config.insert_location`)
|
||||
|
||||
Returns:
|
||||
int: Number of notes updated.
|
||||
"""
|
||||
if location is None:
|
||||
location = self.insert_location
|
||||
|
||||
num_changed = 0
|
||||
|
||||
for _note in self.notes:
|
||||
if _note.add_metadata(area, key, value):
|
||||
for _note in self.notes_in_scope:
|
||||
if _note.add_metadata(area=area, key=key, value=value, location=location):
|
||||
num_changed += 1
|
||||
|
||||
if num_changed > 0:
|
||||
@@ -125,7 +221,7 @@ class Vault:
|
||||
log.debug("Backing up vault")
|
||||
if self.dry_run:
|
||||
alerts.dryrun(f"Backup up vault to: {self.backup_path}")
|
||||
print("\n")
|
||||
console.print("\n")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -143,33 +239,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."""
|
||||
@@ -193,7 +277,7 @@ class Vault:
|
||||
"""
|
||||
num_changed = 0
|
||||
|
||||
for _note in self.notes:
|
||||
for _note in self.notes_in_scope:
|
||||
if _note.delete_inline_tag(tag):
|
||||
num_changed += 1
|
||||
|
||||
@@ -214,7 +298,7 @@ class Vault:
|
||||
"""
|
||||
num_changed = 0
|
||||
|
||||
for _note in self.notes:
|
||||
for _note in self.notes_in_scope:
|
||||
if _note.delete_metadata(key, value):
|
||||
num_changed += 1
|
||||
|
||||
@@ -223,6 +307,48 @@ class Vault:
|
||||
|
||||
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()
|
||||
|
||||
match export_format:
|
||||
case "csv":
|
||||
with open(export_file, "w", encoding="UTF8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(["Metadata Type", "Key", "Value"])
|
||||
|
||||
for key, value in self.metadata.frontmatter.items():
|
||||
if isinstance(value, list):
|
||||
if len(value) > 0:
|
||||
for v in value:
|
||||
writer.writerow(["frontmatter", key, v])
|
||||
else:
|
||||
writer.writerow(["frontmatter", key, v])
|
||||
|
||||
for key, value in self.metadata.inline_metadata.items():
|
||||
if isinstance(value, list):
|
||||
if len(value) > 0:
|
||||
for v in value:
|
||||
writer.writerow(["inline_metadata", key, v])
|
||||
else:
|
||||
writer.writerow(["frontmatter", key, v])
|
||||
for tag in self.metadata.tags:
|
||||
writer.writerow(["tags", "", f"{tag}"])
|
||||
case "json":
|
||||
dict_to_dump = {
|
||||
"frontmatter": self.metadata.dict,
|
||||
"inline_metadata": self.metadata.inline_metadata,
|
||||
"tags": self.metadata.tags,
|
||||
}
|
||||
|
||||
with open(export_file, "w", encoding="UTF8") as f:
|
||||
json.dump(dict_to_dump, f, indent=4, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
def get_changed_notes(self) -> list[Note]:
|
||||
"""Returns a list of notes that have changes.
|
||||
|
||||
@@ -230,7 +356,7 @@ class Vault:
|
||||
list[Note]: List of notes that have changes.
|
||||
"""
|
||||
changed_notes = []
|
||||
for _note in self.notes:
|
||||
for _note in self.notes_in_scope:
|
||||
if _note.has_changes():
|
||||
changed_notes.append(_note)
|
||||
|
||||
@@ -245,36 +371,45 @@ class Vault:
|
||||
table.add_row("Backup path", str(self.backup_path))
|
||||
else:
|
||||
table.add_row("Backup", "None")
|
||||
table.add_row("Notes in scope", str(self.num_notes()))
|
||||
table.add_row("Notes in scope", str(len(self.notes_in_scope)))
|
||||
table.add_row("Notes excluded from scope", str(self.num_excluded_notes()))
|
||||
table.add_row("Active path filter", str(self.path_filter))
|
||||
table.add_row("Notes with updates", str(len(self.get_changed_notes())))
|
||||
table.add_row("Active filters", str(len(self.filters)))
|
||||
table.add_row("Notes with changes", str(len(self.get_changed_notes())))
|
||||
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."""
|
||||
table = Table(title="Notes in current scope", show_header=False, box=box.HORIZONTALS)
|
||||
for _n, _note in enumerate(self.notes, start=1):
|
||||
for _n, _note in enumerate(self.notes_in_scope, start=1):
|
||||
table.add_row(str(_n), str(_note.note_path.relative_to(self.vault_path)))
|
||||
Console().print(table)
|
||||
console.print(table)
|
||||
|
||||
def num_excluded_notes(self) -> int:
|
||||
"""Count number of excluded notes."""
|
||||
excluded_notes = [
|
||||
p.resolve()
|
||||
for p in self.vault_path.glob("**/*")
|
||||
if p.suffix in [".md", ".MD", ".markdown", ".MARKDOWN"] and p not in self.note_paths
|
||||
]
|
||||
return len(excluded_notes)
|
||||
return len(self.all_notes) - len(self.notes_in_scope)
|
||||
|
||||
def num_notes(self) -> int:
|
||||
"""Number of notes in the vault.
|
||||
def rename_inline_tag(self, old_tag: str, new_tag: str) -> int:
|
||||
"""Rename an inline tag in the vault.
|
||||
|
||||
Args:
|
||||
old_tag (str): Old tag name.
|
||||
new_tag (str): New tag name.
|
||||
|
||||
Returns:
|
||||
int: Number of notes in the vault.
|
||||
int: Number of notes that had inline tags renamed.
|
||||
"""
|
||||
return len(self.notes)
|
||||
num_changed = 0
|
||||
|
||||
for _note in self.notes_in_scope:
|
||||
if _note.rename_inline_tag(old_tag, new_tag):
|
||||
num_changed += 1
|
||||
|
||||
if num_changed > 0:
|
||||
self._rebuild_vault_metadata()
|
||||
|
||||
return num_changed
|
||||
|
||||
def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> int:
|
||||
"""Renames a key or key-value pair in the note's metadata.
|
||||
@@ -291,7 +426,7 @@ class Vault:
|
||||
"""
|
||||
num_changed = 0
|
||||
|
||||
for _note in self.notes:
|
||||
for _note in self.notes_in_scope:
|
||||
if _note.rename_metadata(key, value_1, value_2):
|
||||
num_changed += 1
|
||||
|
||||
@@ -300,31 +435,41 @@ class Vault:
|
||||
|
||||
return num_changed
|
||||
|
||||
def rename_inline_tag(self, old_tag: str, new_tag: str) -> int:
|
||||
"""Rename an inline tag in the vault.
|
||||
def transpose_metadata( # noqa: PLR0913
|
||||
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:
|
||||
old_tag (str): Old tag name.
|
||||
new_tag (str): New tag name.
|
||||
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 inline tags renamed.
|
||||
int: Number of notes that had metadata transposed.
|
||||
"""
|
||||
num_changed = 0
|
||||
if location is None:
|
||||
location = self.insert_location
|
||||
|
||||
for _note in self.notes:
|
||||
if _note.rename_inline_tag(old_tag, new_tag):
|
||||
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
|
||||
|
||||
if num_changed > 0:
|
||||
self._rebuild_vault_metadata()
|
||||
|
||||
return num_changed
|
||||
|
||||
def write(self) -> None:
|
||||
"""Write changes to the vault."""
|
||||
log.debug("Writing changes to vault...")
|
||||
if self.dry_run is False:
|
||||
for _note in self.notes:
|
||||
log.trace(f"writing to {_note.note_path}")
|
||||
_note.write()
|
||||
|
||||
@@ -44,6 +44,36 @@ def test_notice(capsys):
|
||||
assert captured.out == "NOTICE | This prints in notice\n"
|
||||
|
||||
|
||||
def test_alerts_debug(capsys):
|
||||
"""Test debug."""
|
||||
alerts.debug("This prints in debug")
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "DEBUG | This prints in debug\n"
|
||||
|
||||
|
||||
def test_usage(capsys):
|
||||
"""Test usage."""
|
||||
alerts.usage("This prints in usage")
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "USAGE | This prints in usage\n"
|
||||
|
||||
alerts.usage(
|
||||
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua"
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert "USAGE | Lorem ipsum dolor sit amet" in captured.out
|
||||
assert " | incididunt ut labore et dolore magna aliqua" in captured.out
|
||||
|
||||
alerts.usage(
|
||||
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
|
||||
width=20,
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert "USAGE | Lorem ipsum dolor" in captured.out
|
||||
assert " | sit amet," in captured.out
|
||||
assert " | adipisicing elit," in captured.out
|
||||
|
||||
|
||||
def test_info(capsys):
|
||||
"""Test info."""
|
||||
alerts.info("This prints in info")
|
||||
|
||||
@@ -13,39 +13,39 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from obsidian_metadata.models.enums import MetadataType
|
||||
from tests.helpers import Regex
|
||||
from tests.helpers import Regex, remove_ansi
|
||||
|
||||
|
||||
def test_instantiate_application(test_application) -> None:
|
||||
"""Test application."""
|
||||
app = test_application
|
||||
app.load_vault()
|
||||
app._load_vault()
|
||||
|
||||
assert app.dry_run is False
|
||||
assert app.config.name == "command_line_vault"
|
||||
assert app.config.exclude_paths == [".git", ".obsidian"]
|
||||
assert app.dry_run is False
|
||||
assert app.vault.num_notes() == 13
|
||||
assert len(app.vault.all_notes) == 13
|
||||
|
||||
|
||||
def test_abort(test_application, mocker, capsys) -> None:
|
||||
"""Test renaming a key."""
|
||||
app = test_application
|
||||
app.load_vault()
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
return_value="abort",
|
||||
)
|
||||
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert "Done!" in captured.out
|
||||
captured = remove_ansi(capsys.readouterr().out)
|
||||
assert "Done!" in captured
|
||||
|
||||
|
||||
def test_add_metadata_frontmatter_success(test_application, mocker, capsys) -> None:
|
||||
def test_add_metadata_frontmatter(test_application, mocker, capsys) -> None:
|
||||
"""Test adding new metadata to the vault."""
|
||||
app = test_application
|
||||
app.load_vault()
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["add_metadata", KeyError],
|
||||
@@ -65,14 +65,64 @@ def test_add_metadata_frontmatter_success(test_application, mocker, capsys) -> N
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"SUCCESS +\| Added metadata to.*\d+.*notes", re.DOTALL)
|
||||
captured = remove_ansi(capsys.readouterr().out)
|
||||
assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL)
|
||||
|
||||
|
||||
def test_add_metadata_inline(test_application, mocker, capsys) -> None:
|
||||
"""Test adding new metadata to the vault."""
|
||||
app = test_application
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["add_metadata", KeyError],
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_area",
|
||||
return_value=MetadataType.INLINE,
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_new_key",
|
||||
return_value="new_key",
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_new_value",
|
||||
return_value="new_key_value",
|
||||
)
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = 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."""
|
||||
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(test_application, mocker, capsys) -> None:
|
||||
"""Test renaming an inline tag."""
|
||||
app = test_application
|
||||
app.load_vault()
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["delete_metadata", KeyError],
|
||||
@@ -88,8 +138,8 @@ def test_delete_inline_tag(test_application, mocker, capsys) -> None:
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"WARNING +\| No notes were changed", re.DOTALL)
|
||||
captured = remove_ansi(capsys.readouterr().out)
|
||||
assert "WARNING | No notes were changed" in captured
|
||||
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
@@ -106,14 +156,14 @@ def test_delete_inline_tag(test_application, mocker, capsys) -> None:
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"SUCCESS +\| Deleted.*\d+.*notes", re.DOTALL)
|
||||
captured = remove_ansi(capsys.readouterr().out)
|
||||
assert captured == Regex(r"SUCCESS +\| Deleted inline tag: breakfast in \d+ notes", re.DOTALL)
|
||||
|
||||
|
||||
def test_delete_key(test_application, mocker, capsys) -> None:
|
||||
"""Test renaming an inline tag."""
|
||||
app = test_application
|
||||
app.load_vault()
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["delete_metadata", KeyError],
|
||||
@@ -129,8 +179,8 @@ def test_delete_key(test_application, mocker, capsys) -> None:
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"WARNING +\| No notes found with a.*key.*matching", re.DOTALL)
|
||||
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",
|
||||
@@ -147,16 +197,14 @@ def test_delete_key(test_application, mocker, capsys) -> None:
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(
|
||||
r"SUCCESS +\|.*Deleted.*keys.*matching:.*d\\w\+.*from.*10", re.DOTALL
|
||||
)
|
||||
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()
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["delete_metadata", KeyError],
|
||||
@@ -175,8 +223,8 @@ def test_delete_value(test_application, mocker, capsys) -> None:
|
||||
)
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"WARNING +\| No notes found matching:", re.DOTALL)
|
||||
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",
|
||||
@@ -196,23 +244,21 @@ def test_delete_value(test_application, mocker, capsys) -> None:
|
||||
)
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(
|
||||
r"SUCCESS +\| Deleted value.*\^front\\w\+\$.*from.*key.*area.*in.*\d+.*notes", re.DOTALL
|
||||
)
|
||||
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_filter(test_application, mocker, capsys) -> None:
|
||||
def test_filter_notes(test_application, mocker, capsys) -> None:
|
||||
"""Test renaming a key."""
|
||||
app = test_application
|
||||
app.load_vault()
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["filter_notes", KeyError],
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_selection",
|
||||
side_effect=["apply_filter", "list_notes", "back"],
|
||||
side_effect=["apply_path_filter", "list_notes", "back"],
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_filter_path",
|
||||
@@ -221,10 +267,10 @@ def test_filter_notes_filter(test_application, mocker, capsys) -> None:
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"SUCCESS +\| Loaded.*\d+.*notes from.*\d+.*total", re.DOTALL)
|
||||
assert "02 inline/inline 2.md" in captured.out
|
||||
assert "03 mixed/mixed 1.md" not in captured.out
|
||||
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",
|
||||
@@ -232,25 +278,61 @@ def test_filter_notes_filter(test_application, mocker, capsys) -> None:
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_selection",
|
||||
side_effect=["apply_filter", "list_notes", "back"],
|
||||
side_effect=["apply_metadata_filter", "list_notes", "back"],
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_filter_path",
|
||||
"obsidian_metadata.models.application.Questions.ask_existing_key",
|
||||
return_value="on_one_note",
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_existing_value",
|
||||
return_value="",
|
||||
)
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"SUCCESS +\| Loaded all.*\d+.*total notes", re.DOTALL)
|
||||
assert captured.out == Regex(r"SUCCESS +\| Loaded.*1.*notes from.*\d+.*total", re.DOTALL)
|
||||
assert "02 inline/inline 2.md" in captured.out
|
||||
assert "03 mixed/mixed 1.md" in captured.out
|
||||
assert "03 mixed/mixed 1.md" not in captured.out
|
||||
|
||||
|
||||
def test_filter_clear(test_application, mocker, capsys) -> None:
|
||||
"""Test clearing filters."""
|
||||
app = test_application
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["filter_notes", "filter_notes", KeyError],
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_selection",
|
||||
side_effect=["apply_metadata_filter", "list_filters", "list_notes", "back"],
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_existing_key",
|
||||
return_value="on_one_note",
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_existing_value",
|
||||
return_value="",
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_number",
|
||||
return_value="1",
|
||||
)
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = 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_inspect_metadata_all(test_application, mocker, capsys) -> None:
|
||||
"""Test backing up a vault."""
|
||||
app = test_application
|
||||
app.load_vault()
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["inspect_metadata", KeyError],
|
||||
@@ -262,14 +344,14 @@ def test_inspect_metadata_all(test_application, mocker, capsys) -> None:
|
||||
)
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"type +│ article", re.DOTALL)
|
||||
captured = remove_ansi(capsys.readouterr().out)
|
||||
assert captured == Regex(r"type +│ article", re.DOTALL)
|
||||
|
||||
|
||||
def test_rename_inline_tag(test_application, mocker, capsys) -> None:
|
||||
"""Test renaming an inline tag."""
|
||||
app = test_application
|
||||
app.load_vault()
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["rename_metadata", KeyError],
|
||||
@@ -289,8 +371,8 @@ def test_rename_inline_tag(test_application, mocker, capsys) -> None:
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"WARNING +\| No notes were changed", re.DOTALL)
|
||||
captured = remove_ansi(capsys.readouterr().out)
|
||||
assert "No notes were changed" in captured
|
||||
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
@@ -311,14 +393,14 @@ def test_rename_inline_tag(test_application, mocker, capsys) -> None:
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"Renamed.*breakfast.*to.*new_tag.*in.*\d+.*notes", re.DOTALL)
|
||||
captured = remove_ansi(capsys.readouterr().out)
|
||||
assert captured == Regex(r"Renamed breakfast to new_tag in \d+ notes", re.DOTALL)
|
||||
|
||||
|
||||
def test_rename_key(test_application, mocker, capsys) -> None:
|
||||
"""Test renaming a key."""
|
||||
app = test_application
|
||||
app.load_vault()
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["rename_metadata", KeyError],
|
||||
@@ -338,8 +420,8 @@ def test_rename_key(test_application, mocker, capsys) -> None:
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert "WARNING | No notes were changed" in captured.out
|
||||
captured = remove_ansi(capsys.readouterr().out)
|
||||
assert "WARNING | No notes were changed" in captured
|
||||
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
@@ -360,14 +442,14 @@ def test_rename_key(test_application, mocker, capsys) -> None:
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"Renamed.*tags.*to.*new_tags.*in.*\d+.*notes", re.DOTALL)
|
||||
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_application_main",
|
||||
side_effect=["rename_metadata", KeyError],
|
||||
@@ -390,8 +472,8 @@ def test_rename_value_fail(test_application, mocker, capsys) -> None:
|
||||
)
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"WARNING +\| No notes were changed", re.DOTALL)
|
||||
captured = remove_ansi(capsys.readouterr().out)
|
||||
assert "WARNING | No notes were changed" in captured
|
||||
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
@@ -415,39 +497,34 @@ def test_rename_value_fail(test_application, mocker, capsys) -> None:
|
||||
)
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(
|
||||
r"SUCCESS +\| Renamed.*'area:frontmatter'.*to.*'area:new_key'", re.DOTALL
|
||||
captured = remove_ansi(capsys.readouterr().out)
|
||||
assert captured == Regex(
|
||||
r"SUCCESS +\| Renamed 'area:frontmatter' to 'area:new_key' in \d+ notes", re.DOTALL
|
||||
)
|
||||
assert captured.out == Regex(r".*in.*\d+.*notes.*", re.DOTALL)
|
||||
|
||||
|
||||
def test_review_no_changes(test_application, mocker, capsys) -> None:
|
||||
"""Review changes when no changes to vault."""
|
||||
app = test_application
|
||||
app.load_vault()
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["review_changes", KeyError],
|
||||
)
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"INFO +\| No changes to review", re.DOTALL)
|
||||
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_application_main",
|
||||
side_effect=["rename_metadata", "review_changes", KeyError],
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_confirm",
|
||||
return_value=True,
|
||||
)
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_existing_key",
|
||||
return_value="tags",
|
||||
@@ -462,16 +539,55 @@ def test_review_changes(test_application, mocker, capsys) -> None:
|
||||
)
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r".*Found.*\d+.*changed notes in the vault.*", re.DOTALL)
|
||||
assert "- tags:" in captured.out
|
||||
assert "+ new_tags:" in captured.out
|
||||
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(test_application, mocker, capsys) -> None:
|
||||
"""Transpose metadata."""
|
||||
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=["transpose_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
|
||||
|
||||
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=["transpose_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()
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["vault_actions", KeyError],
|
||||
@@ -483,8 +599,10 @@ def test_vault_backup(test_application, mocker, capsys) -> None:
|
||||
)
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"SUCCESS +\|.*application\.bak", re.DOTALL)
|
||||
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:
|
||||
@@ -492,7 +610,7 @@ def test_vault_delete(test_application, mocker, capsys, tmp_path) -> None:
|
||||
app = test_application
|
||||
backup_path = Path(tmp_path / "application.bak")
|
||||
backup_path.mkdir()
|
||||
app.load_vault()
|
||||
app._load_vault()
|
||||
mocker.patch(
|
||||
"obsidian_metadata.models.application.Questions.ask_application_main",
|
||||
side_effect=["vault_actions", KeyError],
|
||||
@@ -504,5 +622,5 @@ def test_vault_delete(test_application, mocker, capsys, tmp_path) -> None:
|
||||
)
|
||||
with pytest.raises(KeyError):
|
||||
app.application_main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"SUCCESS +\| Backup deleted", re.DOTALL)
|
||||
captured = remove_ansi(capsys.readouterr().out)
|
||||
assert captured == Regex(r"SUCCESS +\| Backup deleted", re.DOTALL)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,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."""
|
||||
|
||||
6
tests/fixtures/broken_frontmatter.md
vendored
Normal file
6
tests/fixtures/broken_frontmatter.md
vendored
Normal 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
|
||||
5
tests/fixtures/multiple_vaults.toml
vendored
5
tests/fixtures/multiple_vaults.toml
vendored
@@ -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
1
tests/fixtures/no_metadata.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Lorem ipsum dolor sit amet.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
area:: frontmatter
|
||||
date_created:: 2022-12-22
|
||||
date_modified:: 2022-12-22
|
||||
@@ -6,13 +5,17 @@ author:: John Doe
|
||||
status:: new
|
||||
type:: book
|
||||
type:: article
|
||||
on_one_note:: one
|
||||
#food/fruit/apple
|
||||
#food/fruit/pear
|
||||
#dinner #lunch #breakfast
|
||||
|
||||
# note header
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
|
||||
|
||||
### header 3
|
||||
|
||||
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.
|
||||
@@ -1,3 +1,4 @@
|
||||
# Header 1
|
||||
|
||||
area:: frontmatter
|
||||
date_created:: 2022-12-22
|
||||
@@ -10,9 +11,12 @@ type:: article
|
||||
#food/fruit/pear
|
||||
#dinner #lunch #breakfast
|
||||
|
||||
## Header 2
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
|
||||
|
||||
### Header 3
|
||||
|
||||
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.
|
||||
23
tests/fixtures/sample_vault/03 mixed/mixed 1.md
vendored
23
tests/fixtures/sample_vault/03 mixed/mixed 1.md
vendored
@@ -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
7
tests/fixtures/short_textfile.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
key: value
|
||||
---
|
||||
|
||||
# header 1
|
||||
|
||||
Lorem ipsum dolor sit amet.
|
||||
18
tests/fixtures/test_vault/test1.md
vendored
18
tests/fixtures/test_vault/test1.md
vendored
@@ -1,14 +1,16 @@
|
||||
---
|
||||
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 +20,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
|
||||
|
||||
5
tests/fixtures/test_vault_config.toml
vendored
5
tests/fixtures/test_vault_config.toml
vendored
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
"""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 +26,7 @@ METADATA: dict[str, list[str]] = {
|
||||
"top_key3": ["top_key3_value"],
|
||||
"intext_key": ["intext_key_value"],
|
||||
}
|
||||
METADATA_2: dict[str, list[str]] = {"key1": ["value1"], "key2": ["value2", "value3"]}
|
||||
FRONTMATTER_CONTENT: str = """
|
||||
---
|
||||
tags:
|
||||
@@ -41,7 +46,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,129 +68,6 @@ repeated_key:: repeated_key_value2
|
||||
"""
|
||||
|
||||
|
||||
def test_vault_metadata(capsys) -> None:
|
||||
"""Test VaultMetadata class."""
|
||||
vm = VaultMetadata()
|
||||
assert vm.dict == {}
|
||||
|
||||
vm.add_metadata(METADATA)
|
||||
assert vm.dict == {
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "note"],
|
||||
"intext_key": ["intext_key_value"],
|
||||
"shared_key1": ["shared_key1_value"],
|
||||
"shared_key2": ["shared_key2_value"],
|
||||
"tags": ["tag 1", "tag 2", "tag 3"],
|
||||
"top_key1": ["top_key1_value"],
|
||||
"top_key2": ["top_key2_value"],
|
||||
"top_key3": ["top_key3_value"],
|
||||
}
|
||||
|
||||
vm.print_keys()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"frontmatter_Key1 +frontmatter_Key2 +intext_key")
|
||||
|
||||
vm.print_tags()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"tag 1 +tag 2 +tag 3")
|
||||
|
||||
vm.print_metadata()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"┃ Keys +┃ Values +┃")
|
||||
assert captured.out == Regex(r"│ +│ tag 3 +│")
|
||||
assert captured.out == Regex(r"│ frontmatter_Key1 +│ author name +│")
|
||||
|
||||
new_metadata = {"added_key": ["added_value"], "frontmatter_Key2": ["new_value"]}
|
||||
vm.add_metadata(new_metadata)
|
||||
assert vm.dict == {
|
||||
"added_key": ["added_value"],
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "new_value", "note"],
|
||||
"intext_key": ["intext_key_value"],
|
||||
"shared_key1": ["shared_key1_value"],
|
||||
"shared_key2": ["shared_key2_value"],
|
||||
"tags": ["tag 1", "tag 2", "tag 3"],
|
||||
"top_key1": ["top_key1_value"],
|
||||
"top_key2": ["top_key2_value"],
|
||||
"top_key3": ["top_key3_value"],
|
||||
}
|
||||
|
||||
|
||||
def test_vault_metadata_contains() -> None:
|
||||
"""Test contains method."""
|
||||
vm = VaultMetadata()
|
||||
vm.add_metadata(METADATA)
|
||||
assert vm.dict == {
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "note"],
|
||||
"intext_key": ["intext_key_value"],
|
||||
"shared_key1": ["shared_key1_value"],
|
||||
"shared_key2": ["shared_key2_value"],
|
||||
"tags": ["tag 1", "tag 2", "tag 3"],
|
||||
"top_key1": ["top_key1_value"],
|
||||
"top_key2": ["top_key2_value"],
|
||||
"top_key3": ["top_key3_value"],
|
||||
}
|
||||
|
||||
assert vm.contains("frontmatter_Key1") is True
|
||||
assert vm.contains("frontmatter_Key2", "article") is True
|
||||
assert vm.contains("frontmatter_Key3") is False
|
||||
assert vm.contains("frontmatter_Key2", "no value") is False
|
||||
assert vm.contains("1$", is_regex=True) is True
|
||||
assert vm.contains("5$", is_regex=True) is False
|
||||
assert vm.contains("tags", r"\d", is_regex=True) is True
|
||||
assert vm.contains("tags", r"^\d", is_regex=True) is False
|
||||
|
||||
|
||||
def test_vault_metadata_delete() -> None:
|
||||
"""Test delete method."""
|
||||
vm = VaultMetadata()
|
||||
vm.add_metadata(METADATA)
|
||||
assert vm.dict == {
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "note"],
|
||||
"intext_key": ["intext_key_value"],
|
||||
"shared_key1": ["shared_key1_value"],
|
||||
"shared_key2": ["shared_key2_value"],
|
||||
"tags": ["tag 1", "tag 2", "tag 3"],
|
||||
"top_key1": ["top_key1_value"],
|
||||
"top_key2": ["top_key2_value"],
|
||||
"top_key3": ["top_key3_value"],
|
||||
}
|
||||
|
||||
assert vm.delete("no key") is False
|
||||
assert vm.delete("tags", "no value") is False
|
||||
assert vm.delete("tags", "tag 2") is True
|
||||
assert vm.dict["tags"] == ["tag 1", "tag 3"]
|
||||
assert vm.delete("tags") is True
|
||||
assert "tags" not in vm.dict
|
||||
|
||||
|
||||
def test_vault_metadata_rename() -> None:
|
||||
"""Test rename method."""
|
||||
vm = VaultMetadata()
|
||||
vm.add_metadata(METADATA)
|
||||
assert vm.dict == {
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "note"],
|
||||
"intext_key": ["intext_key_value"],
|
||||
"shared_key1": ["shared_key1_value"],
|
||||
"shared_key2": ["shared_key2_value"],
|
||||
"tags": ["tag 1", "tag 2", "tag 3"],
|
||||
"top_key1": ["top_key1_value"],
|
||||
"top_key2": ["top_key2_value"],
|
||||
"top_key3": ["top_key3_value"],
|
||||
}
|
||||
|
||||
assert vm.rename("no key", "new key") is False
|
||||
assert vm.rename("tags", "no tag", "new key") is False
|
||||
assert vm.rename("tags", "tag 2", "new tag") is True
|
||||
assert vm.dict["tags"] == ["new tag", "tag 1", "tag 3"]
|
||||
assert vm.rename("tags", "old_tags") is True
|
||||
assert vm.dict["old_tags"] == ["new tag", "tag 1", "tag 3"]
|
||||
assert "tags" not in vm.dict
|
||||
|
||||
|
||||
def test_frontmatter_create() -> None:
|
||||
"""Test frontmatter creation."""
|
||||
frontmatter = Frontmatter(INLINE_CONTENT)
|
||||
@@ -207,6 +88,22 @@ def test_frontmatter_create() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_frontmatter_create_error() -> None:
|
||||
"""Test frontmatter creation error.
|
||||
|
||||
GIVEN frontmatter content
|
||||
WHEN frontmatter is invalid
|
||||
THEN raise ValueError
|
||||
"""
|
||||
fn = """---
|
||||
tags: tag
|
||||
invalid = = "content"
|
||||
---
|
||||
"""
|
||||
with pytest.raises(AttributeError):
|
||||
Frontmatter(fn)
|
||||
|
||||
|
||||
def test_frontmatter_contains() -> None:
|
||||
"""Test frontmatter contains."""
|
||||
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
|
||||
@@ -365,6 +262,84 @@ tags:
|
||||
assert frontmatter.to_yaml(sort_keys=True) == new_frontmatter_sorted
|
||||
|
||||
|
||||
def test_inline_metadata_add() -> None:
|
||||
"""Test inline add."""
|
||||
inline = InlineMetadata(INLINE_CONTENT)
|
||||
|
||||
assert inline.add("bold_key1") is False
|
||||
assert inline.add("bold_key1", "bold_key1_value") is False
|
||||
assert inline.add("added_key") is True
|
||||
assert inline.dict == {
|
||||
"added_key": [],
|
||||
"bold_key1": ["bold_key1_value"],
|
||||
"bold_key2": ["bold_key2_value"],
|
||||
"emoji_📅_key": ["emoji_📅_key_value"],
|
||||
"in_text_key1": ["in_text_key1_value"],
|
||||
"in_text_key2": ["in_text_key2_value"],
|
||||
"link_key": ["link_key_value"],
|
||||
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
|
||||
"tag_key": ["tag_key_value"],
|
||||
}
|
||||
|
||||
assert inline.add("added_key1", "added_value") is True
|
||||
assert inline.dict == {
|
||||
"added_key": [],
|
||||
"added_key1": ["added_value"],
|
||||
"bold_key1": ["bold_key1_value"],
|
||||
"bold_key2": ["bold_key2_value"],
|
||||
"emoji_📅_key": ["emoji_📅_key_value"],
|
||||
"in_text_key1": ["in_text_key1_value"],
|
||||
"in_text_key2": ["in_text_key2_value"],
|
||||
"link_key": ["link_key_value"],
|
||||
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
|
||||
"tag_key": ["tag_key_value"],
|
||||
}
|
||||
|
||||
assert inline.dict == {
|
||||
"added_key": [],
|
||||
"added_key1": ["added_value"],
|
||||
"bold_key1": ["bold_key1_value"],
|
||||
"bold_key2": ["bold_key2_value"],
|
||||
"emoji_📅_key": ["emoji_📅_key_value"],
|
||||
"in_text_key1": ["in_text_key1_value"],
|
||||
"in_text_key2": ["in_text_key2_value"],
|
||||
"link_key": ["link_key_value"],
|
||||
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
|
||||
"tag_key": ["tag_key_value"],
|
||||
}
|
||||
|
||||
assert inline.add("added_key", "added_value")
|
||||
assert inline.dict == {
|
||||
"added_key": ["added_value"],
|
||||
"added_key1": ["added_value"],
|
||||
"bold_key1": ["bold_key1_value"],
|
||||
"bold_key2": ["bold_key2_value"],
|
||||
"emoji_📅_key": ["emoji_📅_key_value"],
|
||||
"in_text_key1": ["in_text_key1_value"],
|
||||
"in_text_key2": ["in_text_key2_value"],
|
||||
"link_key": ["link_key_value"],
|
||||
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
|
||||
"tag_key": ["tag_key_value"],
|
||||
}
|
||||
assert inline.add("repeated_key", "repeated_key_value1") is False
|
||||
assert inline.add("repeated_key", "new_value") is True
|
||||
|
||||
|
||||
def test_inline_metadata_contains() -> None:
|
||||
"""Test inline metadata contains method."""
|
||||
inline = InlineMetadata(INLINE_CONTENT)
|
||||
|
||||
assert inline.contains("bold_key1") is True
|
||||
assert inline.contains("bold_key2", "bold_key2_value") is True
|
||||
assert inline.contains("bold_key3") is False
|
||||
assert inline.contains("bold_key2", "no value") is False
|
||||
|
||||
assert inline.contains(r"\w{4}_key", is_regex=True) is True
|
||||
assert inline.contains(r"^\d", is_regex=True) is False
|
||||
assert inline.contains("1$", r"\d_value", is_regex=True) is True
|
||||
assert inline.contains("key", r"^\d_value", is_regex=True) is False
|
||||
|
||||
|
||||
def test_inline_metadata_create() -> None:
|
||||
"""Test inline metadata creation."""
|
||||
inline = InlineMetadata(FRONTMATTER_CONTENT)
|
||||
@@ -392,46 +367,6 @@ def test_inline_metadata_create() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_inline_contains() -> None:
|
||||
"""Test inline metadata contains method."""
|
||||
inline = InlineMetadata(INLINE_CONTENT)
|
||||
|
||||
assert inline.contains("bold_key1") is True
|
||||
assert inline.contains("bold_key2", "bold_key2_value") is True
|
||||
assert inline.contains("bold_key3") is False
|
||||
assert inline.contains("bold_key2", "no value") is False
|
||||
|
||||
assert inline.contains(r"\w{4}_key", is_regex=True) is True
|
||||
assert inline.contains(r"^\d", is_regex=True) is False
|
||||
assert inline.contains("1$", r"\d_value", is_regex=True) is True
|
||||
assert inline.contains("key", r"^\d_value", is_regex=True) is False
|
||||
|
||||
|
||||
def test_inline_metadata_rename() -> None:
|
||||
"""Test inline metadata rename."""
|
||||
inline = InlineMetadata(INLINE_CONTENT)
|
||||
assert inline.dict == {
|
||||
"bold_key1": ["bold_key1_value"],
|
||||
"bold_key2": ["bold_key2_value"],
|
||||
"emoji_📅_key": ["emoji_📅_key_value"],
|
||||
"in_text_key1": ["in_text_key1_value"],
|
||||
"in_text_key2": ["in_text_key2_value"],
|
||||
"link_key": ["link_key_value"],
|
||||
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
|
||||
"tag_key": ["tag_key_value"],
|
||||
}
|
||||
|
||||
assert inline.rename("no key", "new key") is False
|
||||
assert inline.rename("repeated_key", "no value", "new key") is False
|
||||
assert inline.has_changes() is False
|
||||
assert inline.rename("repeated_key", "repeated_key_value1", "new value") is True
|
||||
assert inline.dict["repeated_key"] == ["new value", "repeated_key_value2"]
|
||||
assert inline.rename("repeated_key", "old_key") is True
|
||||
assert inline.dict["old_key"] == ["new value", "repeated_key_value2"]
|
||||
assert "repeated_key" not in inline.dict
|
||||
assert inline.has_changes() is True
|
||||
|
||||
|
||||
def test_inline_metadata_delete() -> None:
|
||||
"""Test inline metadata delete."""
|
||||
inline = InlineMetadata(INLINE_CONTENT)
|
||||
@@ -473,6 +408,57 @@ def test_inline_metadata_delete() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_inline_metadata_rename() -> None:
|
||||
"""Test inline metadata rename."""
|
||||
inline = InlineMetadata(INLINE_CONTENT)
|
||||
assert inline.dict == {
|
||||
"bold_key1": ["bold_key1_value"],
|
||||
"bold_key2": ["bold_key2_value"],
|
||||
"emoji_📅_key": ["emoji_📅_key_value"],
|
||||
"in_text_key1": ["in_text_key1_value"],
|
||||
"in_text_key2": ["in_text_key2_value"],
|
||||
"link_key": ["link_key_value"],
|
||||
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
|
||||
"tag_key": ["tag_key_value"],
|
||||
}
|
||||
|
||||
assert inline.rename("no key", "new key") is False
|
||||
assert inline.rename("repeated_key", "no value", "new key") is False
|
||||
assert inline.has_changes() is False
|
||||
assert inline.rename("repeated_key", "repeated_key_value1", "new value") is True
|
||||
assert inline.dict["repeated_key"] == ["new value", "repeated_key_value2"]
|
||||
assert inline.rename("repeated_key", "old_key") is True
|
||||
assert inline.dict["old_key"] == ["new value", "repeated_key_value2"]
|
||||
assert "repeated_key" not in inline.dict
|
||||
assert inline.has_changes() is True
|
||||
|
||||
|
||||
def test_inline_tags_add() -> None:
|
||||
"""Test inline tags add."""
|
||||
tags = InlineTags(INLINE_CONTENT)
|
||||
|
||||
assert tags.add("bold_tag") is False
|
||||
assert tags.add("new_tag") is True
|
||||
assert tags.list == [
|
||||
"bold_tag",
|
||||
"in_text_tag",
|
||||
"inline_tag_top1",
|
||||
"inline_tag_top2",
|
||||
"new_tag",
|
||||
"tag_key_value",
|
||||
]
|
||||
|
||||
|
||||
def test_inline_tags_contains() -> None:
|
||||
"""Test inline tags contains."""
|
||||
tags = InlineTags(INLINE_CONTENT)
|
||||
assert tags.contains("bold_tag") is True
|
||||
assert tags.contains("no tag") is False
|
||||
|
||||
assert tags.contains(r"\w_\w", is_regex=True) is True
|
||||
assert tags.contains(r"\d_\d", is_regex=True) is False
|
||||
|
||||
|
||||
def test_inline_tags_create() -> None:
|
||||
"""Test inline tags creation."""
|
||||
tags = InlineTags(FRONTMATTER_CONTENT)
|
||||
@@ -496,40 +482,6 @@ def test_inline_tags_create() -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_inline_tags_contains() -> None:
|
||||
"""Test inline tags contains."""
|
||||
tags = InlineTags(INLINE_CONTENT)
|
||||
assert tags.contains("bold_tag") is True
|
||||
assert tags.contains("no tag") is False
|
||||
|
||||
assert tags.contains(r"\w_\w", is_regex=True) is True
|
||||
assert tags.contains(r"\d_\d", is_regex=True) is False
|
||||
|
||||
|
||||
def test_inline_tags_rename() -> None:
|
||||
"""Test inline tags rename."""
|
||||
tags = InlineTags(INLINE_CONTENT)
|
||||
assert tags.list == [
|
||||
"bold_tag",
|
||||
"in_text_tag",
|
||||
"inline_tag_top1",
|
||||
"inline_tag_top2",
|
||||
"tag_key_value",
|
||||
]
|
||||
|
||||
assert tags.rename("no tag", "new tag") is False
|
||||
assert tags.has_changes() is False
|
||||
assert tags.rename("bold_tag", "new tag") is True
|
||||
assert tags.list == [
|
||||
"in_text_tag",
|
||||
"inline_tag_top1",
|
||||
"inline_tag_top2",
|
||||
"new tag",
|
||||
"tag_key_value",
|
||||
]
|
||||
assert tags.has_changes() is True
|
||||
|
||||
|
||||
def test_inline_tags_delete() -> None:
|
||||
"""Test inline tags delete."""
|
||||
tags = InlineTags(INLINE_CONTENT)
|
||||
@@ -554,3 +506,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
|
||||
|
||||
1170
tests/notes_test.py
1170
tests/notes_test.py
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import pytest
|
||||
|
||||
from obsidian_metadata.models.patterns import Patterns
|
||||
|
||||
TAG_CONTENT: str = "#1 #2 **#3** [[#4]] [[#5|test]] #6#notag #7_8 #9/10 #11-12 #13; #14, #15. #16: #17* #18(#19) #20[#21] #22\\ #23& #24# #25 **#26** #📅/tag"
|
||||
TAG_CONTENT: str = "#1 #2 **#3** [[#4]] [[#5|test]] #6#notag #7_8 #9/10 #11-12 #13; #14, #15. #16: #17* #18(#19) #20[#21] #22\\ #23& #24# #25 **#26** #📅/tag [link](#no_tag) https://example.com/somepage.html_#no_url_tags"
|
||||
INLINE_METADATA: str = """
|
||||
**1:: 1**
|
||||
2_2:: [[2_2]] | 2
|
||||
@@ -56,10 +56,69 @@ shared_key1: 'shared_key1_value'
|
||||
"""
|
||||
|
||||
|
||||
def test_regex():
|
||||
"""Test regexes."""
|
||||
def test_top_with_header():
|
||||
"""Test identifying the top of a note."""
|
||||
pattern = Patterns()
|
||||
|
||||
no_fm_or_header = """
|
||||
|
||||
|
||||
Lorem ipsum dolor sit amet.
|
||||
|
||||
# header 1
|
||||
---
|
||||
horizontal: rule
|
||||
---
|
||||
Lorem ipsum dolor sit amet.
|
||||
"""
|
||||
fm_and_header: str = """
|
||||
---
|
||||
tags:
|
||||
- tag_1
|
||||
- tag_2
|
||||
-
|
||||
- 📅/tag_3
|
||||
frontmatter_Key1: "frontmatter_Key1_value"
|
||||
frontmatter_Key2: ["note", "article"]
|
||||
shared_key1: 'shared_key1_value'
|
||||
---
|
||||
|
||||
# Header 1
|
||||
more content
|
||||
|
||||
---
|
||||
horizontal: rule
|
||||
---
|
||||
"""
|
||||
fm_and_header_result = """---
|
||||
tags:
|
||||
- tag_1
|
||||
- tag_2
|
||||
-
|
||||
- 📅/tag_3
|
||||
frontmatter_Key1: "frontmatter_Key1_value"
|
||||
frontmatter_Key2: ["note", "article"]
|
||||
shared_key1: 'shared_key1_value'
|
||||
---
|
||||
|
||||
# Header 1"""
|
||||
no_fm = """
|
||||
|
||||
### Header's number 3 [📅] "+$2.00" 🤷
|
||||
---
|
||||
horizontal: rule
|
||||
---
|
||||
"""
|
||||
no_fm_result = '### Header\'s number 3 [📅] "+$2.00" 🤷'
|
||||
|
||||
assert pattern.top_with_header.search(no_fm_or_header).group("top") == ""
|
||||
assert pattern.top_with_header.search(fm_and_header).group("top") == fm_and_header_result
|
||||
assert pattern.top_with_header.search(no_fm).group("top") == no_fm_result
|
||||
|
||||
|
||||
def test_find_inline_tags():
|
||||
"""Test find_inline_tags regex."""
|
||||
pattern = Patterns()
|
||||
assert pattern.find_inline_tags.findall(TAG_CONTENT) == [
|
||||
"1",
|
||||
"2",
|
||||
@@ -87,6 +146,11 @@ def test_regex():
|
||||
"📅/tag",
|
||||
]
|
||||
|
||||
|
||||
def test_find_inline_metadata():
|
||||
"""Test find_inline_metadata regex."""
|
||||
pattern = Patterns()
|
||||
|
||||
result = pattern.find_inline_metadata.findall(INLINE_METADATA)
|
||||
assert result == [
|
||||
("", "", "1", "1**"),
|
||||
@@ -99,14 +163,26 @@ def test_regex():
|
||||
("", "", "emoji_📅_key", "📅emoji_📅_key_value"),
|
||||
]
|
||||
|
||||
found = pattern.frontmatt_block_with_separators.search(FRONTMATTER_CONTENT).group("frontmatter")
|
||||
|
||||
def test_find_frontmatter():
|
||||
"""Test regexes."""
|
||||
pattern = Patterns()
|
||||
found = pattern.frontmatter_block.search(FRONTMATTER_CONTENT).group("frontmatter")
|
||||
assert found == CORRECT_FRONTMATTER_WITH_SEPARATORS
|
||||
|
||||
found = pattern.frontmatt_block_no_separators.search(FRONTMATTER_CONTENT).group("frontmatter")
|
||||
found = pattern.frontmatt_block_strip_separators.search(FRONTMATTER_CONTENT).group(
|
||||
"frontmatter"
|
||||
)
|
||||
assert found == CORRECT_FRONTMATTER_NO_SEPARATORS
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
pattern.frontmatt_block_no_separators.search(TAG_CONTENT).group("frontmatter")
|
||||
pattern.frontmatt_block_strip_separators.search(TAG_CONTENT).group("frontmatter")
|
||||
|
||||
|
||||
def test_validators():
|
||||
"""Test validators."""
|
||||
pattern = Patterns()
|
||||
|
||||
assert pattern.validate_tag_text.search("test_tag") is None
|
||||
assert pattern.validate_tag_text.search("#asdf").group(0) == "#"
|
||||
assert pattern.validate_tag_text.search("#asdf").group(0) == "#"
|
||||
|
||||
@@ -60,6 +60,14 @@ def test_validate_new_tag() -> None:
|
||||
assert questions._validate_new_tag("new_tag") is True
|
||||
|
||||
|
||||
def test_validate_number() -> None:
|
||||
"""Test number validation."""
|
||||
questions = Questions(vault=VAULT)
|
||||
assert "Must be an integer" in questions._validate_number("test")
|
||||
assert "Must be an integer" in questions._validate_number("1.1")
|
||||
assert questions._validate_number("1") is True
|
||||
|
||||
|
||||
def test_validate_existing_inline_tag() -> None:
|
||||
"""Test existing tag validation."""
|
||||
questions = Questions(vault=VAULT)
|
||||
@@ -80,12 +88,10 @@ def test_validate_key_exists_regex() -> None:
|
||||
def test_validate_value() -> None:
|
||||
"""Test value validation."""
|
||||
questions = Questions(vault=VAULT)
|
||||
assert questions._validate_value("test") is True
|
||||
assert "Value cannot be empty" in questions._validate_value("")
|
||||
|
||||
assert questions._validate_value("test") is True
|
||||
questions2 = Questions(vault=VAULT, key="frontmatter_Key1")
|
||||
assert questions2._validate_value("test") == "frontmatter_Key1:test does not exist"
|
||||
assert "Value cannot be empty" in questions2._validate_value("")
|
||||
assert questions2._validate_value("author name") is True
|
||||
|
||||
|
||||
@@ -104,7 +110,7 @@ def test_validate_value_exists_regex() -> None:
|
||||
def test_validate_new_value() -> None:
|
||||
"""Test new value validation."""
|
||||
questions = Questions(vault=VAULT, key="frontmatter_Key1")
|
||||
assert questions._validate_new_value("new_value") is True
|
||||
assert questions._validate_new_value("not_exists") is True
|
||||
assert "Value cannot be empty" in questions._validate_new_value("")
|
||||
assert (
|
||||
questions._validate_new_value("author name")
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
from pathlib import Path
|
||||
|
||||
from obsidian_metadata._config import Config
|
||||
from obsidian_metadata.models import Vault
|
||||
from obsidian_metadata.models.enums import MetadataType
|
||||
from obsidian_metadata.models import Vault, VaultFilter
|
||||
from obsidian_metadata.models.enums import InsertLocation, MetadataType
|
||||
from tests.helpers import Regex
|
||||
|
||||
|
||||
@@ -16,33 +16,29 @@ def test_vault_creation(test_vault):
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config)
|
||||
|
||||
assert vault.name == "vault"
|
||||
assert vault.vault_path == vault_path
|
||||
assert vault.insert_location == InsertLocation.BOTTOM
|
||||
assert vault.backup_path == Path(f"{vault_path}.bak")
|
||||
assert vault.dry_run is False
|
||||
assert str(vault.exclude_paths[0]) == Regex(r".*\.git")
|
||||
assert vault.num_notes() == 3
|
||||
assert len(vault.all_notes) == 3
|
||||
|
||||
assert vault.metadata.dict == {
|
||||
"Inline Tags": [
|
||||
"ignored_file_tag2",
|
||||
"inline_tag_bottom1",
|
||||
"inline_tag_bottom2",
|
||||
"inline_tag_top1",
|
||||
"inline_tag_top2",
|
||||
"intext_tag1",
|
||||
"intext_tag2",
|
||||
"shared_tag",
|
||||
],
|
||||
"author": ["author name"],
|
||||
"bottom_key1": ["bottom_key1_value"],
|
||||
"bottom_key2": ["bottom_key2_value"],
|
||||
"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",
|
||||
@@ -58,22 +54,170 @@ def test_vault_creation(test_vault):
|
||||
"type": ["article", "note"],
|
||||
}
|
||||
|
||||
assert vault.metadata.tags == [
|
||||
"ignored_file_tag2",
|
||||
"inline_tag_bottom1",
|
||||
"inline_tag_bottom2",
|
||||
"inline_tag_top1",
|
||||
"inline_tag_top2",
|
||||
"intext_tag1",
|
||||
"intext_tag2",
|
||||
"shared_tag",
|
||||
]
|
||||
assert vault.metadata.inline_metadata == {
|
||||
"bottom_key1": ["bottom_key1_value"],
|
||||
"bottom_key2": ["bottom_key2_value"],
|
||||
"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 == {
|
||||
"author": ["author name"],
|
||||
"date_created": ["2022-12-22"],
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "note"],
|
||||
"ignored_frontmatter": ["ignore_me"],
|
||||
"shared_key1": ["shared_key1_value", "shared_key1_value3"],
|
||||
"shared_key2": ["shared_key2_value1"],
|
||||
"tags": [
|
||||
"frontmatter_tag1",
|
||||
"frontmatter_tag2",
|
||||
"frontmatter_tag3",
|
||||
"ignored_file_tag1",
|
||||
"shared_tag",
|
||||
"📅/frontmatter_tag3",
|
||||
],
|
||||
"type": ["article", "note"],
|
||||
}
|
||||
|
||||
def test_get_filtered_notes(sample_vault) -> None:
|
||||
"""Test filtering notes."""
|
||||
vault_path = sample_vault
|
||||
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
|
||||
|
||||
def set_insert_location(test_vault):
|
||||
"""Test setting a new insert location."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config, path_filter="front")
|
||||
vault = Vault(config=vault_config)
|
||||
|
||||
assert vault.num_notes() == 4
|
||||
assert vault.name == "vault"
|
||||
assert vault.vault_path == vault_path
|
||||
assert vault.insert_location == InsertLocation.BOTTOM
|
||||
vault.insert_location = InsertLocation.TOP
|
||||
assert vault.insert_location == InsertLocation.TOP
|
||||
|
||||
vault_path = sample_vault
|
||||
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
|
||||
|
||||
def test_add_metadata(test_vault) -> None:
|
||||
"""Test adding metadata to the vault."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault2 = Vault(config=vault_config, path_filter="mixed")
|
||||
vault = Vault(config=vault_config)
|
||||
|
||||
assert vault2.num_notes() == 1
|
||||
assert vault.add_metadata(MetadataType.FRONTMATTER, "new_key") == 3
|
||||
assert vault.metadata.dict == {
|
||||
"author": ["author name"],
|
||||
"bottom_key1": ["bottom_key1_value"],
|
||||
"bottom_key2": ["bottom_key2_value"],
|
||||
"date_created": ["2022-12-22"],
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "note"],
|
||||
"ignored_frontmatter": ["ignore_me"],
|
||||
"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",
|
||||
"frontmatter_tag3",
|
||||
"ignored_file_tag1",
|
||||
"shared_tag",
|
||||
"📅/frontmatter_tag3",
|
||||
],
|
||||
"top_key1": ["top_key1_value"],
|
||||
"top_key2": ["top_key2_value"],
|
||||
"top_key3": ["top_key3_value_as_link"],
|
||||
"type": ["article", "note"],
|
||||
}
|
||||
assert vault.metadata.frontmatter == {
|
||||
"author": ["author name"],
|
||||
"date_created": ["2022-12-22"],
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "note"],
|
||||
"ignored_frontmatter": ["ignore_me"],
|
||||
"new_key": [],
|
||||
"shared_key1": ["shared_key1_value", "shared_key1_value3"],
|
||||
"shared_key2": ["shared_key2_value1"],
|
||||
"tags": [
|
||||
"frontmatter_tag1",
|
||||
"frontmatter_tag2",
|
||||
"frontmatter_tag3",
|
||||
"ignored_file_tag1",
|
||||
"shared_tag",
|
||||
"📅/frontmatter_tag3",
|
||||
],
|
||||
"type": ["article", "note"],
|
||||
}
|
||||
assert vault.add_metadata(MetadataType.FRONTMATTER, "new_key2", "new_key2_value") == 3
|
||||
assert vault.metadata.dict == {
|
||||
"author": ["author name"],
|
||||
"bottom_key1": ["bottom_key1_value"],
|
||||
"bottom_key2": ["bottom_key2_value"],
|
||||
"date_created": ["2022-12-22"],
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "note"],
|
||||
"ignored_frontmatter": ["ignore_me"],
|
||||
"intext_key": ["intext_value"],
|
||||
"key📅": ["📅_key_value"],
|
||||
"new_key": [],
|
||||
"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",
|
||||
"frontmatter_tag3",
|
||||
"ignored_file_tag1",
|
||||
"shared_tag",
|
||||
"📅/frontmatter_tag3",
|
||||
],
|
||||
"top_key1": ["top_key1_value"],
|
||||
"top_key2": ["top_key2_value"],
|
||||
"top_key3": ["top_key3_value_as_link"],
|
||||
"type": ["article", "note"],
|
||||
}
|
||||
assert vault.metadata.frontmatter == {
|
||||
"author": ["author name"],
|
||||
"date_created": ["2022-12-22"],
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "note"],
|
||||
"ignored_frontmatter": ["ignore_me"],
|
||||
"new_key": [],
|
||||
"new_key2": ["new_key2_value"],
|
||||
"shared_key1": ["shared_key1_value", "shared_key1_value3"],
|
||||
"shared_key2": ["shared_key2_value1"],
|
||||
"tags": [
|
||||
"frontmatter_tag1",
|
||||
"frontmatter_tag2",
|
||||
"frontmatter_tag3",
|
||||
"ignored_file_tag1",
|
||||
"shared_tag",
|
||||
"📅/frontmatter_tag3",
|
||||
],
|
||||
"type": ["article", "note"],
|
||||
}
|
||||
|
||||
|
||||
def test_backup(test_vault, capsys):
|
||||
@@ -95,6 +239,34 @@ def test_backup(test_vault, capsys):
|
||||
assert captured.out == Regex(r"Backup path +\│[\s ]+/[\d\w]+")
|
||||
|
||||
|
||||
def test_commit(test_vault, tmp_path):
|
||||
"""Test committing changes to content in the vault."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config)
|
||||
content = Path(f"{tmp_path}/vault/test1.md").read_text()
|
||||
assert "new_key: new_key_value" not in content
|
||||
|
||||
vault.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_key_value")
|
||||
vault.commit_changes()
|
||||
assert "new_key: new_key_value" not in content
|
||||
|
||||
|
||||
def test_commit_dry_run(test_vault, tmp_path):
|
||||
"""Test committing changes to content in the vault in dry run mode."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config, dry_run=True)
|
||||
content = Path(f"{tmp_path}/vault/test1.md").read_text()
|
||||
assert "new_key: new_key_value" not in content
|
||||
|
||||
vault.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_key_value")
|
||||
vault.commit_changes()
|
||||
assert "new_key: new_key_value" not in content
|
||||
|
||||
|
||||
def test_backup_dryrun(test_vault, capsys):
|
||||
"""Test backing up the vault."""
|
||||
vault_path = test_vault
|
||||
@@ -145,142 +317,6 @@ def test_delete_backup_dryrun(test_vault, capsys):
|
||||
assert vault.backup_path.exists() is True
|
||||
|
||||
|
||||
def test_info(test_vault, capsys):
|
||||
"""Test printing vault information."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config)
|
||||
|
||||
vault.info()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"Vault +\│ /[\d\w]+")
|
||||
assert captured.out == Regex(r"Notes in scope +\│ \d+")
|
||||
assert captured.out == Regex(r"Backup +\│ None")
|
||||
|
||||
|
||||
def test_list_editable_notes(test_vault, capsys) -> None:
|
||||
"""Test listing editable notes."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config)
|
||||
|
||||
vault.list_editable_notes()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex("Notes in current scope")
|
||||
assert captured.out == Regex(r"1 +test1\.md")
|
||||
|
||||
|
||||
def test_contains_inline_tag(test_vault) -> None:
|
||||
"""Test if the vault contains an inline tag."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config)
|
||||
|
||||
assert vault.contains_inline_tag("tag") is False
|
||||
assert vault.contains_inline_tag("intext_tag2") is True
|
||||
|
||||
|
||||
def test_add_metadata(test_vault) -> None:
|
||||
"""Test adding metadata to the vault."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config)
|
||||
|
||||
assert vault.add_metadata(MetadataType.FRONTMATTER, "new_key") == 3
|
||||
assert vault.metadata.dict == {
|
||||
"Inline Tags": [
|
||||
"ignored_file_tag2",
|
||||
"inline_tag_bottom1",
|
||||
"inline_tag_bottom2",
|
||||
"inline_tag_top1",
|
||||
"inline_tag_top2",
|
||||
"intext_tag1",
|
||||
"intext_tag2",
|
||||
"shared_tag",
|
||||
],
|
||||
"author": ["author name"],
|
||||
"bottom_key1": ["bottom_key1_value"],
|
||||
"bottom_key2": ["bottom_key2_value"],
|
||||
"date_created": ["2022-12-22"],
|
||||
"emoji_📅_key": ["emoji_📅_key_value"],
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "note"],
|
||||
"ignored_frontmatter": ["ignore_me"],
|
||||
"intext_key": ["intext_value"],
|
||||
"new_key": [],
|
||||
"shared_key1": ["shared_key1_value"],
|
||||
"shared_key2": ["shared_key2_value1", "shared_key2_value2"],
|
||||
"tags": [
|
||||
"frontmatter_tag1",
|
||||
"frontmatter_tag2",
|
||||
"frontmatter_tag3",
|
||||
"ignored_file_tag1",
|
||||
"shared_tag",
|
||||
"📅/frontmatter_tag3",
|
||||
],
|
||||
"top_key1": ["top_key1_value"],
|
||||
"top_key2": ["top_key2_value"],
|
||||
"top_key3": ["top_key3_value_as_link"],
|
||||
"type": ["article", "note"],
|
||||
}
|
||||
assert vault.add_metadata(MetadataType.FRONTMATTER, "new_key2", "new_key2_value") == 3
|
||||
assert vault.metadata.dict == {
|
||||
"Inline Tags": [
|
||||
"ignored_file_tag2",
|
||||
"inline_tag_bottom1",
|
||||
"inline_tag_bottom2",
|
||||
"inline_tag_top1",
|
||||
"inline_tag_top2",
|
||||
"intext_tag1",
|
||||
"intext_tag2",
|
||||
"shared_tag",
|
||||
],
|
||||
"author": ["author name"],
|
||||
"bottom_key1": ["bottom_key1_value"],
|
||||
"bottom_key2": ["bottom_key2_value"],
|
||||
"date_created": ["2022-12-22"],
|
||||
"emoji_📅_key": ["emoji_📅_key_value"],
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "note"],
|
||||
"ignored_frontmatter": ["ignore_me"],
|
||||
"intext_key": ["intext_value"],
|
||||
"new_key": [],
|
||||
"new_key2": ["new_key2_value"],
|
||||
"shared_key1": ["shared_key1_value"],
|
||||
"shared_key2": ["shared_key2_value1", "shared_key2_value2"],
|
||||
"tags": [
|
||||
"frontmatter_tag1",
|
||||
"frontmatter_tag2",
|
||||
"frontmatter_tag3",
|
||||
"ignored_file_tag1",
|
||||
"shared_tag",
|
||||
"📅/frontmatter_tag3",
|
||||
],
|
||||
"top_key1": ["top_key1_value"],
|
||||
"top_key2": ["top_key2_value"],
|
||||
"top_key3": ["top_key3_value_as_link"],
|
||||
"type": ["article", "note"],
|
||||
}
|
||||
|
||||
|
||||
def test_contains_metadata(test_vault) -> None:
|
||||
"""Test if the vault contains a metadata key."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config)
|
||||
|
||||
assert vault.contains_metadata("key") is False
|
||||
assert vault.contains_metadata("top_key1") is True
|
||||
assert vault.contains_metadata("top_key1", "no_value") is False
|
||||
assert vault.contains_metadata("top_key1", "top_key1_value") is True
|
||||
|
||||
|
||||
def test_delete_inline_tag(test_vault) -> None:
|
||||
"""Test deleting an inline tag."""
|
||||
vault_path = test_vault
|
||||
@@ -290,7 +326,7 @@ def test_delete_inline_tag(test_vault) -> None:
|
||||
|
||||
assert vault.delete_inline_tag("no tag") == 0
|
||||
assert vault.delete_inline_tag("intext_tag2") == 2
|
||||
assert vault.metadata.dict["Inline Tags"] == [
|
||||
assert vault.metadata.tags == [
|
||||
"ignored_file_tag2",
|
||||
"inline_tag_bottom1",
|
||||
"inline_tag_bottom2",
|
||||
@@ -318,6 +354,97 @@ def test_delete_metadata(test_vault) -> None:
|
||||
assert "top_key2" not in vault.metadata.dict
|
||||
|
||||
|
||||
def test_export_csv(tmp_path, test_vault):
|
||||
"""Test exporting the vault to a CSV file."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config)
|
||||
export_file = Path(f"{tmp_path}/export.csv")
|
||||
|
||||
vault.export_metadata(path=export_file, export_format="csv")
|
||||
assert export_file.exists() is True
|
||||
assert "frontmatter,date_created,2022-12-22" in export_file.read_text()
|
||||
|
||||
|
||||
def test_export_json(tmp_path, test_vault):
|
||||
"""Test exporting the vault to a CSV file."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config)
|
||||
export_file = Path(f"{tmp_path}/export.json")
|
||||
|
||||
vault.export_metadata(path=export_file, export_format="json")
|
||||
assert export_file.exists() is True
|
||||
assert '"frontmatter": {' in export_file.read_text()
|
||||
|
||||
|
||||
def test_get_filtered_notes(sample_vault) -> None:
|
||||
"""Test filtering notes."""
|
||||
vault_path = sample_vault
|
||||
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
|
||||
filters = [VaultFilter(path_filter="front")]
|
||||
vault = Vault(config=vault_config, filters=filters)
|
||||
assert len(vault.all_notes) == 13
|
||||
assert len(vault.notes_in_scope) == 4
|
||||
|
||||
filters = [VaultFilter(path_filter="mixed")]
|
||||
vault = Vault(config=vault_config, filters=filters)
|
||||
assert len(vault.all_notes) == 13
|
||||
assert len(vault.notes_in_scope) == 1
|
||||
|
||||
filters = [VaultFilter(key_filter="on_one_note")]
|
||||
vault = Vault(config=vault_config, filters=filters)
|
||||
assert len(vault.all_notes) == 13
|
||||
assert len(vault.notes_in_scope) == 1
|
||||
|
||||
filters = [VaultFilter(key_filter="type", value_filter="book")]
|
||||
vault = Vault(config=vault_config, filters=filters)
|
||||
assert len(vault.all_notes) == 13
|
||||
assert len(vault.notes_in_scope) == 10
|
||||
|
||||
filters = [VaultFilter(tag_filter="brunch")]
|
||||
vault = Vault(config=vault_config, filters=filters)
|
||||
assert len(vault.all_notes) == 13
|
||||
assert len(vault.notes_in_scope) == 1
|
||||
|
||||
filters = [VaultFilter(tag_filter="brunch"), VaultFilter(path_filter="inbox")]
|
||||
vault = Vault(config=vault_config, filters=filters)
|
||||
assert len(vault.all_notes) == 13
|
||||
assert len(vault.notes_in_scope) == 0
|
||||
|
||||
|
||||
def test_info(test_vault, capsys):
|
||||
"""Test printing vault information."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config)
|
||||
|
||||
vault.info()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex(r"Vault +\│ /[\d\w]+")
|
||||
assert captured.out == Regex(r"Notes in scope +\│ \d+")
|
||||
assert captured.out == Regex(r"Backup +\│ None")
|
||||
|
||||
|
||||
def test_list_editable_notes(test_vault, capsys) -> None:
|
||||
"""Test listing editable notes."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
vault_config = config.vaults[0]
|
||||
vault = Vault(config=vault_config)
|
||||
|
||||
vault.list_editable_notes()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == Regex("Notes in current scope")
|
||||
assert captured.out == Regex(r"\d +test1\.md")
|
||||
|
||||
|
||||
def test_rename_inline_tag(test_vault) -> None:
|
||||
"""Test renaming an inline tag."""
|
||||
vault_path = test_vault
|
||||
@@ -327,7 +454,7 @@ def test_rename_inline_tag(test_vault) -> None:
|
||||
|
||||
assert vault.rename_inline_tag("no tag", "new_tag") == 0
|
||||
assert vault.rename_inline_tag("intext_tag2", "new_tag") == 2
|
||||
assert vault.metadata.dict["Inline Tags"] == [
|
||||
assert vault.metadata.tags == [
|
||||
"ignored_file_tag2",
|
||||
"inline_tag_bottom1",
|
||||
"inline_tag_bottom2",
|
||||
@@ -369,3 +496,51 @@ def test_rename_metadata(test_vault) -> None:
|
||||
"shared_tag",
|
||||
"📅/frontmatter_tag3",
|
||||
]
|
||||
|
||||
|
||||
def test_transpose_metadata(test_vault) -> None:
|
||||
"""Test transposing metadata."""
|
||||
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.transpose_metadata(begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER) == 2
|
||||
|
||||
assert vault.metadata.inline_metadata == {}
|
||||
assert vault.metadata.frontmatter == {
|
||||
"author": ["author name"],
|
||||
"bottom_key1": ["bottom_key1_value"],
|
||||
"bottom_key2": ["bottom_key2_value"],
|
||||
"date_created": ["2022-12-22"],
|
||||
"frontmatter_Key1": ["author name"],
|
||||
"frontmatter_Key2": ["article", "note"],
|
||||
"ignored_frontmatter": ["ignore_me"],
|
||||
"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",
|
||||
"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.transpose_metadata(
|
||||
begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER, location=InsertLocation.TOP
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user