45 Commits

Author SHA1 Message Date
Nathaniel Landau
28c721f6d9 bump(release): v0.12.0 → v0.12.1 2023-09-02 17:05:01 -04:00
Nathaniel Landau
10449b3e6a fix(notes): preserve file encoding when writing to filesystem (#59) 2023-09-02 17:03:06 -04:00
Nathaniel Landau
22e9719402 build(precommit): lint typos 2023-08-31 08:53:42 -04:00
Nathaniel Landau
461a067115 build(deps): updte dependencies 2023-08-28 09:29:47 -04:00
Nathaniel Landau
34aa78c103 build(deps): bump dependencies 2023-07-05 09:48:44 -04:00
Nathaniel Landau
d78e5d1218 bump(release): v0.11.1 → v0.12.0 2023-05-17 09:15:01 -04:00
Nathaniel Landau
476ca62e5c build(deps): update sh from 2.0.3 to 2.0.4 2023-05-17 09:08:48 -04:00
Nathaniel Landau
30009ada8f fix: allow markdown inline code in metadata values 2023-05-17 09:07:47 -04:00
Nathaniel Landau
00990db77a build(deps): bump dependencies 2023-05-14 16:46:14 -04:00
Nathaniel Landau
ac487db3fd fix: only ask for valid metadata types when adding new metadata 2023-05-06 14:52:06 -04:00
Nathaniel Landau
b762c34860 ci: update harden security runner (#42) 2023-05-05 14:51:32 -04:00
Nathaniel Landau
2d15760096 build(deps): bump all dependencies 2023-05-05 14:27:30 -04:00
Nathaniel Landau
dbf1cc8e13 build(deps): bump ruff to v0.0.264 2023-05-05 13:14:03 -04:00
Nathaniel Landau
2e61a92ad1 feat: greatly improve capturing all formats of inline metadata (#41)
feat: greatly improve capturing metadata all formats of inline metadata
2023-05-05 13:09:59 -04:00
dependabot[bot]
9ec6919022 ci(deps): bump step-security/harden-runner from 2.2.0 to 2.2.1 (#33)
Bumps [step-security/harden-runner](https://github.com/step-security/harden-runner) from 2.2.0 to 2.2.1.
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](c8454efe5d...1f99358870)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-04 09:09:18 -04:00
Nathaniel Landau
72fef38b0f fix: convert charsets to utf-8 when necessary (#32)
improve compatibility on Windows
2023-03-30 17:37:32 -04:00
Nathaniel Landau
4df10e785e fix: improve TOML error handing and docs for Windows paths (#31)
* fix: improve TOML error handing and documentation for Windows paths

* build(linting): pash ruff v2.0.260
2023-03-30 10:33:51 -04:00
Nathaniel Landau
5a4643ea8f bump(release): v0.11.0 → v0.11.1 2023-03-29 13:44:29 -04:00
Nathaniel Landau
c5766af678 fix: add custom exceptions (#29)
* feat: add custom exceptions to metadata creation

* refactor: utility function for finding inline metadata in content

* fix: use InlineTagError for exceptions parsing inline tags

* fix: improve error messages

* build(deps): bump dependencies

* fix: use BadParameter exception when appropriate
2023-03-29 13:31:23 -04:00
Nathaniel Landau
375dceb8c6 build(deps): bump dependencies 2023-03-26 14:41:38 -04:00
Nathaniel Landau
c75d18200e bump(release): v0.10.0 → v0.11.0 2023-03-24 15:50:59 -04:00
Nathaniel Landau
ffdac91537 feat: add --import-csv option to cli 2023-03-24 15:38:42 -04:00
Nathaniel Landau
e8f408ee33 build(deps): bump dependencies 2023-03-23 10:20:54 -04:00
Nathaniel Landau
1dd3ddfb22 test: improve tests for utilities 2023-03-23 09:50:19 -04:00
Nathaniel Landau
8968127c95 bump(release): v0.9.0 → v0.10.0 2023-03-21 23:18:04 -04:00
Nathaniel Landau
4bf1acb775 fix: --export-template correctly exports all notes 2023-03-21 23:16:23 -04:00
Nathaniel Landau
98fa996462 fix: --export-csv exports csv not json 2023-03-21 23:04:40 -04:00
Nathaniel Landau
fdb1b8b5bc refactor: pave the way for non-regex key/value deletions 2023-03-21 23:00:35 -04:00
Nathaniel Landau
08999cb055 feat: add --export-template cli option 2023-03-21 18:00:32 -04:00
Nathaniel Landau
4e053bda29 refactor: remove unused code 2023-03-21 17:17:10 -04:00
Nathaniel Landau
fa568de369 refactor: cleanup rename and delete from dict functions 2023-03-21 17:15:49 -04:00
Nathaniel Landau
696e19f3e2 fix(csv-import): fail if type does not validate 2023-03-21 09:51:48 -04:00
Nathaniel Landau
7b762f1a11 docs: cleanup readme 2023-03-20 18:29:17 -04:00
Nathaniel Landau
c1a40ed8a4 bump(release): v0.8.0 → v0.9.0 2023-03-20 18:20:10 -04:00
Nathaniel Landau
6f14076e33 fix: find more instances of inline metadata 2023-03-20 18:15:05 -04:00
Nathaniel Landau
ca42823a2f fix: ensure frontmatter values are unique within a key 2023-03-20 13:59:58 -04:00
Nathaniel Landau
36adfece51 fix: improve validation of bulk imports 2023-03-20 12:56:22 -04:00
Nathaniel Landau
d636fb2672 feat: bulk update metadata from a CSV file 2023-03-20 00:19:12 -04:00
Nathaniel Landau
593dbc3b55 build: add script to bump dependencies 2023-03-18 19:17:23 -04:00
Nathaniel Landau
009801a691 style: pass additional linters 2023-03-17 14:30:50 -04:00
Nathaniel Landau
2493db5f23 fix: improve logging to screen 2023-03-13 07:56:49 -04:00
Nathaniel Landau
a2d69d034d bump(release): v0.7.0 → v0.8.0 2023-03-12 14:11:00 -04:00
Nathaniel Landau
556acc0d46 docs: include move metadata in documentation 2023-03-12 14:08:44 -04:00
Nathaniel Landau
8cefca2639 feat: move inline metadata to specific location in note (#27) 2023-03-12 13:58:55 -04:00
Nathaniel Landau
82e1cba34a fix: add back option to transpose menus 2023-03-12 11:19:53 -04:00
52 changed files with 6376 additions and 5732 deletions

View File

@@ -38,7 +38,8 @@ jobs:
matrix: matrix:
python-version: ["3.10", "3.11"] python-version: ["3.10", "3.11"]
steps: steps:
- uses: step-security/harden-runner@c8454efe5d0bdefd25384362fe217428ca277d57 # v2.2.0 - name: Harden Security Runner
uses: step-security/harden-runner@v2
with: with:
egress-policy: block egress-policy: block
disable-sudo: true disable-sudo: true

View File

@@ -22,8 +22,8 @@ jobs:
pull-requests: read # for wagoid/commitlint-github-action to get commits in PR pull-requests: read # for wagoid/commitlint-github-action to get commits in PR
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Security Runner
uses: step-security/harden-runner@c8454efe5d0bdefd25384362fe217428ca277d57 # v2.2.0 uses: step-security/harden-runner@v2
with: with:
egress-policy: block egress-policy: block
allowed-endpoints: > allowed-endpoints: >

View File

@@ -22,7 +22,8 @@ jobs:
matrix: matrix:
python-version: ["3.11"] python-version: ["3.11"]
steps: steps:
- uses: step-security/harden-runner@c8454efe5d0bdefd25384362fe217428ca277d57 # v2.2.0 - name: Harden Security Runner
uses: step-security/harden-runner@v2
with: with:
egress-policy: block egress-policy: block
disable-sudo: true disable-sudo: true

View File

@@ -27,17 +27,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: step-security/harden-runner@c8454efe5d0bdefd25384362fe217428ca277d57 # v2.2.0 - name: Harden Security Runner
uses: step-security/harden-runner@v2
with: with:
egress-policy: block egress-policy: block
allowed-endpoints: > allowed-endpoints: >
*.data.mcr.microsoft.com:443
api.snapcraft.io:443 api.snapcraft.io:443
auth.docker.io:443 auth.docker.io:443
centralus.data.mcr.microsoft.com:443
deb.debian.org:443 deb.debian.org:443
deb.debian.org:80 deb.debian.org:80
dl.yarnpkg.com:443 dl.yarnpkg.com:443
eastus.data.mcr.microsoft.com:443
files.pythonhosted.org:443 files.pythonhosted.org:443
ghcr.io:443 ghcr.io:443
git.rootprojects.org:443 git.rootprojects.org:443
@@ -51,8 +51,6 @@ jobs:
registry-1.docker.io:443 registry-1.docker.io:443
registry.npmjs.org:443 registry.npmjs.org:443
webi.sh:443 webi.sh:443
westcentralus.data.mcr.microsoft.com:443
westus.data.mcr.microsoft.com:443
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3

View File

@@ -10,8 +10,8 @@ jobs:
pull-requests: write pull-requests: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Security Runner
uses: step-security/harden-runner@c8454efe5d0bdefd25384362fe217428ca277d57 # v2.2.0 uses: step-security/harden-runner@v2
with: with:
egress-policy: block egress-policy: block
allowed-endpoints: > allowed-endpoints: >

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ default_stages: [commit, manual]
fail_fast: true fail_fast: true
repos: repos:
- repo: "https://github.com/commitizen-tools/commitizen" - repo: "https://github.com/commitizen-tools/commitizen"
rev: v2.42.1 rev: 3.7.0
hooks: hooks:
- id: commitizen - id: commitizen
- id: commitizen-branch - id: commitizen-branch
@@ -54,30 +54,39 @@ repos:
types: [python] types: [python]
- repo: "https://github.com/adrienverge/yamllint.git" - repo: "https://github.com/adrienverge/yamllint.git"
rev: v1.29.0 rev: v1.32.0
hooks: hooks:
- id: yamllint - id: yamllint
files: ^.*\.(yaml|yml)$ files: ^.*\.(yaml|yml)$
entry: yamllint --strict --config-file .yamllint.yml entry: yamllint --strict --config-file .yamllint.yml
- repo: "https://github.com/charliermarsh/ruff-pre-commit" - repo: "https://github.com/charliermarsh/ruff-pre-commit"
rev: "v0.0.254" rev: "v0.0.286"
hooks: hooks:
- id: ruff - id: ruff
args: ["--extend-ignore", "I001,D301,D401"] args: ["--extend-ignore", "I001,D301,D401"]
exclude: tests/ exclude: tests/
- repo: "https://github.com/jendrikseipp/vulture" - repo: "https://github.com/jendrikseipp/vulture"
rev: "v2.7" rev: "v2.9.1"
hooks: hooks:
- id: vulture - id: vulture
- repo: "https://github.com/crate-ci/typos"
rev: "v1.16.8"
hooks:
- id: typos
- repo: local - repo: local
hooks: hooks:
- id: custom # This calls a custom pre-commit script.
name: custom pre-commit script # Disable if you don't have it.
entry: scripts/pre-commit-hook.sh - id: stopwords
name: stopwords
entry: bash -c '~/bin/git-stopwords ${PWD}/"$@"'
language: system language: system
pass_filenames: true
types: [text]
- id: black - id: black
name: black name: black

7
.typos.toml Normal file
View File

@@ -0,0 +1,7 @@
[default]
default.locale = "en_us"
[default.extend-words]
nd = "nd" # In the context of 2nd
[files]
extend-exclude = ["*_cache", ".venv", "src/jdfile/utils/strings.py", "tests/fixtures/"]

View File

@@ -1,3 +1,76 @@
## v0.12.1 (2023-09-02)
### Fix
- **notes**: preserve file encoding when writing to filesystem (#59)
## v0.12.0 (2023-05-17)
### Feat
- greatly improve capturing all formats of inline metadata (#41)
- greatly improve capturing metadata all formats of inline metadata
### Fix
- allow markdown inline code in metadata values
- only ask for valid metadata types when adding new metadata
- convert charsets to utf-8 when necessary (#32)
- improve TOML error handing and docs for Windows paths (#31)
## v0.11.1 (2023-03-29)
### Fix
- add custom exceptions (#29)
## v0.11.0 (2023-03-24)
### Feat
- add `--import-csv` option to cli
## v0.10.0 (2023-03-21)
### Feat
- add `--export-template` cli option
### Fix
- `--export-template` correctly exports all notes
- `--export-csv` exports csv not json
- **csv-import**: fail if `type` does not validate
### Refactor
- pave the way for non-regex key/value deletions
- remove unused code
- cleanup rename and delete from dict functions
## v0.9.0 (2023-03-20)
### Feat
- bulk update metadata from a CSV file
### Fix
- find more instances of inline metadata
- ensure frontmatter values are unique within a key
- improve validation of bulk imports
- improve logging to screen
## v0.8.0 (2023-03-12)
### Feat
- move inline metadata to specific location in note (#27)
### Fix
- add `back` option to transpose menus
## v0.7.0 (2023-03-11) ## v0.7.0 (2023-03-11)
### Feat ### Feat
@@ -28,7 +101,7 @@
### Fix ### Fix
- **ui**: add seperator to top of select lists - **ui**: add separator to top of select lists
- allow adding inline tags with same key different values (#17) - allow adding inline tags with same key different values (#17)
- remove unnecessary question when viewing diffs - remove unnecessary question when viewing diffs

View File

@@ -25,8 +25,10 @@ pip install obsidian-metadata
- `--config-file`: Specify a custom configuration file location - `--config-file`: Specify a custom configuration file location
- `--dry-run`: Make no destructive changes - `--dry-run`: Make no destructive changes
- `--import-csv` Import a CSV file with bulk updates
- `--export-csv`: Specify a path and create a CSV export of all metadata - `--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 - `--export-json`: Specify a path and create a JSON export of all metadata
- `--export-template`: Specify a path and export all notes with their associated metadata to a CSV file for use as a bulk import template
- `--help`: Shows interactive help and exits - `--help`: Shows interactive help and exits
- `--log-file`: Specify a log file location - `--log-file`: Specify a log file location
- `--log-to-file`: Will log to a file - `--log-to-file`: Will log to a file
@@ -43,13 +45,18 @@ Once installed, run `obsidian-metadata` in your terminal to enter an interactive
- Backup: Create a backup of the vault. - Backup: Create a backup of the vault.
- Delete Backup: Delete a backup of the vault. - Delete Backup: Delete a backup of the vault.
**Export Metadata**
- Export all metadata to a CSV organized by metadata type
- Export all metadata to a CSV organized by note path
- Export all metadata to a JSON file organized by metadata type
**Inspect Metadata** **Inspect Metadata**
- **View all metadata in the vault** - **View all metadata in the vault**
- View all **frontmatter** - View all **frontmatter**
- View all **inline metadata** - View all **inline metadata**
- View all **inline tags** - View all **inline tags**
- **Export all metadata to CSV or JSON file**
**Filter Notes in Scope**: Limit the scope of notes to be processed with one or more filters. **Filter Notes in Scope**: Limit the scope of notes to be processed with one or more filters.
@@ -59,7 +66,10 @@ Once installed, run `obsidian-metadata` in your terminal to enter an interactive
- **List and clear filters**: List all current filters and clear one or all - **List and clear filters**: List all current filters and clear one or all
- **List notes in scope**: List notes that will be processed. - **List notes in scope**: List notes that will be processed.
**Bulk Edit Metadata** from a CSV file (See the _[Make Bulk Updates](https://github.com/natelandau/obsidian-metadata#make-bulk-updates)_ section below)
**Add Metadata**: Add new metadata to your vault. **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. When adding a new key to inline metadata, the `insert location` value in the config file will specify where in the note it will be inserted.
- **Add new metadata to the frontmatter** - **Add new metadata to the frontmatter**
@@ -78,7 +88,14 @@ When adding a new key to inline metadata, the `insert location` value in the con
- **Delete a value from a key** - **Delete a value from a key**
- **Delete an inline tag** - **Delete an inline tag**
**Move Inline Metadata**: Move inline metadata to a specified location with a note
- **Move to Top**: Move all inline metadata beneath the frontmatter
- **Move to After Title**: Move all inline metadata beneath the first markdown header
- **Move to Bottom**: Move all inline metadata to the bottom of the note
**Transpose Metadata**: Move metadata from inline to frontmatter or the reverse. **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. 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 all metadata** - Moves all frontmatter to inline metadata, or the reverse
@@ -93,6 +110,35 @@ When transposing to inline metadata, the `insert location` value in the config f
- **Commit changes to the vault** - **Commit changes to the vault**
### Known Limitations
Multi-level frontmatter is not supported.
```yaml
# This works perfectly well
---
key: "value"
key2:
- one
- two
- three
key3: ["foo", "bar", "baz"]
key4: value
# This will not work
---
key1:
key2:
- one
- two
- three
key3:
- one
- two
- three
---
```
### Configuration ### 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.
@@ -105,6 +151,8 @@ Below is an example with two vaults.
["Vault One"] # Name of the vault. ["Vault One"] # Name of the vault.
# Path to your obsidian vault # Path to your obsidian vault
# Note for Windows users: Windows paths must use `\\` as the path separator due to a limitation with how TOML parses strings.
# Example: "C:\\Users\\username\\Documents\\Obsidian"
path = "/path/to/vault" path = "/path/to/vault"
# Folders within the vault to ignore when indexing metadata # Folders within the vault to ignore when indexing metadata
@@ -124,6 +172,45 @@ Below is an example with two vaults.
To bypass the configuration file and specify a vault to use at runtime use the `--vault-path` option. To bypass the configuration file and specify a vault to use at runtime use the `--vault-path` option.
**Note for Windows users:**
Due to how TOMML parses strings, Windows paths must use `\\` as the path separator.
For example: `C:\\Users\\username\\Documents\\Obsidian`
### Make Bulk Updates
Bulk edits are supported by importing a CSV file containing the following columns. Column headers must be lowercase.
1. `path` - Path to note relative to the vault root folder
2. `type` - Type of metadata. One of `frontmatter`, `inline_metadata`, or `tag`
3. `key` - The key to add (leave blank for a tag)
4. `value` - the value to add to the key
An example valid CSV file is
```csv
path,type,key,value
folder 1/note1.md,frontmatter,fruits,apple
folder 1/note1.md,frontmatter,fruits,banana
folder 1/note1.md,inline_metadata,cars,toyota
folder 1/note1.md,inline_metadata,cars,honda
folder 1/note1.md,tag,,tag1
folder 1/note1.md,tag,,tag2
```
How bulk imports work:
- **Only notes which match the path in the CSV file are updated**
- **Effected notes will have ALL of their metadata changed** to reflect the values in the CSV file
- **Existing metadata in a matching note will be rewritten**. This may result in it's location and/or formatting within the note being changed
- Inline tags ignore any value added to the `key` column
Create a CSV template for making bulk updates containing all your notes and their associated metadata by
1. Using the `--export-template` cli command; or
2. Selecting the `Metadata by note` option within the `Export Metadata` section of the app
Once you have a template created you can import it using the `--import-csv` flag or by navigating to the `Import bulk changes from CSV` option.
# Contributing # Contributing
## Setup: Once per project ## Setup: Once per project

1212
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,821 +0,0 @@
#!/usr/bin/env bash
# shellcheck disable=SC2317
_mainScript_() {
_customStopWords_() {
# DESC: Check if any specified stop words are in the commit diff. If found, the pre-commit hook will exit with a non-zero exit code.
# ARGS:
# $1 (Required): Path to file
# OUTS:
# 0: Success
# 1: Failure
# USAGE:
# _customStopWords_ "/path/to/file.sh"
# NOTE:
# Requires a plaintext stopword file located at
# `~/.git_stop_words` containing one stopword per line.
[[ $# == 0 ]] && fatal "Missing required argument to ${FUNCNAME[0]}"
local _gitDiffTmp
local FILE_TO_CHECK="${1}"
_gitDiffTmp="${TMP_DIR}/${RANDOM}.${RANDOM}.${RANDOM}.diff.txt"
if [ -f "${STOP_WORD_FILE}" ]; then
if [[ $(basename "${STOP_WORD_FILE}") == "$(basename "${FILE_TO_CHECK}")" ]]; then
debug "$(basename "${1}"): Don't check stop words file for stop words."
return 0
fi
debug "$(basename "${FILE_TO_CHECK}"): Checking for stop words..."
# remove blank lines from stopwords file
sed '/^$/d' "${STOP_WORD_FILE}" >"${TMP_DIR}/pattern_file.txt"
# Check for stopwords
if git diff --cached -- "${FILE_TO_CHECK}" | grep i -q "new file mode"; then
if grep -i --file="${TMP_DIR}/pattern_file.txt" "${FILE_TO_CHECK}"; then
return 1
else
return 0
fi
else
# Add diff to a temporary file
git diff --cached -- "${FILE_TO_CHECK}" | grep '^+' >"${_gitDiffTmp}"
if grep -i --file="${TMP_DIR}/pattern_file.txt" "${_gitDiffTmp}"; then
return 1
else
return 0
fi
fi
else
notice "Could not find git stopwords file expected at '${STOP_WORD_FILE}'. Continuing..."
return 0
fi
}
# Don;t lint binary files
if [[ ${ARGS[0]} =~ \.(jpg|jpeg|gif|png|exe|zip|gzip|tiff|tar|dmg|ttf|otf|m4a|mp3|mkv|mov|avi|eot|svg|woff2?|aac|wav|flac|pdf|doc|xls|ppt|7z|bin|dmg|dat|sql|ico|mpe?g)$ ]]; then
_safeExit_ 0
fi
if ! _customStopWords_ "${ARGS[0]}"; then
error "Stop words found in ${ARGS[0]}"
_safeExit_ 1
fi
}
# end _mainScript_
# ################################## Flags and defaults
# Required variables
LOGFILE="${HOME}/logs/$(basename "$0").log"
QUIET=false
LOGLEVEL=ERROR
VERBOSE=false
FORCE=false
DRYRUN=false
declare -a ARGS=()
# Script specific
LOGLEVEL=NONE
STOP_WORD_FILE="${HOME}/.git_stop_words"
shopt -s nocasematch
# ################################## Custom utility functions (Pasted from repository)
# ################################## Functions required for this template to work
_setColors_() {
# DESC:
# Sets colors use for alerts.
# ARGS:
# None
# OUTS:
# None
# USAGE:
# printf "%s\n" "${blue}Some text${reset}"
if tput setaf 1 >/dev/null 2>&1; then
bold=$(tput bold)
underline=$(tput smul)
reverse=$(tput rev)
reset=$(tput sgr0)
if [[ $(tput colors) -ge 256 ]] >/dev/null 2>&1; then
white=$(tput setaf 231)
blue=$(tput setaf 38)
yellow=$(tput setaf 11)
green=$(tput setaf 82)
red=$(tput setaf 9)
purple=$(tput setaf 171)
gray=$(tput setaf 250)
else
white=$(tput setaf 7)
blue=$(tput setaf 38)
yellow=$(tput setaf 3)
green=$(tput setaf 2)
red=$(tput setaf 9)
purple=$(tput setaf 13)
gray=$(tput setaf 7)
fi
else
bold="\033[4;37m"
reset="\033[0m"
underline="\033[4;37m"
# shellcheck disable=SC2034
reverse=""
white="\033[0;37m"
blue="\033[0;34m"
yellow="\033[0;33m"
green="\033[1;32m"
red="\033[0;31m"
purple="\033[0;35m"
gray="\033[0;37m"
fi
}
_alert_() {
# DESC:
# Controls all printing of messages to log files and stdout.
# ARGS:
# $1 (required) - The type of alert to print
# (success, header, notice, dryrun, debug, warning, error,
# fatal, info, input)
# $2 (required) - The message to be printed to stdout and/or a log file
# $3 (optional) - Pass '${LINENO}' to print the line number where the _alert_ was triggered
# OUTS:
# stdout: The message is printed to stdout
# log file: The message is printed to a log file
# USAGE:
# [_alertType] "[MESSAGE]" "${LINENO}"
# NOTES:
# - The colors of each alert type are set in this function
# - For specified alert types, the funcstac will be printed
local _color
local _alertType="${1}"
local _message="${2}"
local _line="${3-}" # Optional line number
[[ $# -lt 2 ]] && fatal 'Missing required argument to _alert_'
if [[ -n ${_line} && ${_alertType} =~ ^fatal && ${FUNCNAME[2]} != "_trapCleanup_" ]]; then
_message="${_message} ${gray}(line: ${_line}) $(_printFuncStack_)"
elif [[ -n ${_line} && ${FUNCNAME[2]} != "_trapCleanup_" ]]; then
_message="${_message} ${gray}(line: ${_line})"
elif [[ -z ${_line} && ${_alertType} =~ ^fatal && ${FUNCNAME[2]} != "_trapCleanup_" ]]; then
_message="${_message} ${gray}$(_printFuncStack_)"
fi
if [[ ${_alertType} =~ ^(error|fatal) ]]; then
_color="${bold}${red}"
elif [ "${_alertType}" == "info" ]; then
_color="${gray}"
elif [ "${_alertType}" == "warning" ]; then
_color="${red}"
elif [ "${_alertType}" == "success" ]; then
_color="${green}"
elif [ "${_alertType}" == "debug" ]; then
_color="${purple}"
elif [ "${_alertType}" == "header" ]; then
_color="${bold}${white}${underline}"
elif [ "${_alertType}" == "notice" ]; then
_color="${bold}"
elif [ "${_alertType}" == "input" ]; then
_color="${bold}${underline}"
elif [ "${_alertType}" = "dryrun" ]; then
_color="${blue}"
else
_color=""
fi
_writeToScreen_() {
("${QUIET}") && return 0 # Print to console when script is not 'quiet'
[[ ${VERBOSE} == false && ${_alertType} =~ ^(debug|verbose) ]] && return 0
if ! [[ -t 1 || -z ${TERM-} ]]; then # Don't use colors on non-recognized terminals
_color=""
reset=""
fi
if [[ ${_alertType} == header ]]; then
printf "${_color}%s${reset}\n" "${_message}"
else
printf "${_color}[%7s] %s${reset}\n" "${_alertType}" "${_message}"
fi
}
_writeToScreen_
_writeToLog_() {
[[ ${_alertType} == "input" ]] && return 0
[[ ${LOGLEVEL} =~ (off|OFF|Off) ]] && return 0
if [ -z "${LOGFILE-}" ]; then
LOGFILE="$(pwd)/$(basename "$0").log"
fi
[ ! -d "$(dirname "${LOGFILE}")" ] && mkdir -p "$(dirname "${LOGFILE}")"
[[ ! -f ${LOGFILE} ]] && touch "${LOGFILE}"
# Don't use colors in logs
local _cleanmessage
_cleanmessage="$(printf "%s" "${_message}" | sed -E 's/(\x1b)?\[(([0-9]{1,2})(;[0-9]{1,3}){0,2})?[mGK]//g')"
# Print message to log file
printf "%s [%7s] %s %s\n" "$(date +"%b %d %R:%S")" "${_alertType}" "[$(/bin/hostname)]" "${_cleanmessage}" >>"${LOGFILE}"
}
# Write specified log level data to logfile
case "${LOGLEVEL:-ERROR}" in
ALL | all | All)
_writeToLog_
;;
DEBUG | debug | Debug)
_writeToLog_
;;
INFO | info | Info)
if [[ ${_alertType} =~ ^(error|fatal|warning|info|notice|success) ]]; then
_writeToLog_
fi
;;
NOTICE | notice | Notice)
if [[ ${_alertType} =~ ^(error|fatal|warning|notice|success) ]]; then
_writeToLog_
fi
;;
WARN | warn | Warn)
if [[ ${_alertType} =~ ^(error|fatal|warning) ]]; then
_writeToLog_
fi
;;
ERROR | error | Error)
if [[ ${_alertType} =~ ^(error|fatal) ]]; then
_writeToLog_
fi
;;
FATAL | fatal | Fatal)
if [[ ${_alertType} =~ ^fatal ]]; then
_writeToLog_
fi
;;
OFF | off)
return 0
;;
*)
if [[ ${_alertType} =~ ^(error|fatal) ]]; then
_writeToLog_
fi
;;
esac
} # /_alert_
error() { _alert_ error "${1}" "${2-}"; }
warning() { _alert_ warning "${1}" "${2-}"; }
notice() { _alert_ notice "${1}" "${2-}"; }
info() { _alert_ info "${1}" "${2-}"; }
success() { _alert_ success "${1}" "${2-}"; }
dryrun() { _alert_ dryrun "${1}" "${2-}"; }
input() { _alert_ input "${1}" "${2-}"; }
header() { _alert_ header "${1}" "${2-}"; }
debug() { _alert_ debug "${1}" "${2-}"; }
fatal() {
_alert_ fatal "${1}" "${2-}"
_safeExit_ "1"
}
_printFuncStack_() {
# DESC:
# Prints the function stack in use. Used for debugging, and error reporting.
# ARGS:
# None
# OUTS:
# stdout: Prints [function]:[file]:[line]
# NOTE:
# Does not print functions from the alert class
local _i
declare -a _funcStackResponse=()
for ((_i = 1; _i < ${#BASH_SOURCE[@]}; _i++)); do
case "${FUNCNAME[${_i}]}" in
_alert_ | _trapCleanup_ | fatal | error | warning | notice | info | debug | dryrun | header | success)
continue
;;
*)
_funcStackResponse+=("${FUNCNAME[${_i}]}:$(basename "${BASH_SOURCE[${_i}]}"):${BASH_LINENO[_i - 1]}")
;;
esac
done
printf "( "
printf %s "${_funcStackResponse[0]}"
printf ' < %s' "${_funcStackResponse[@]:1}"
printf ' )\n'
}
_safeExit_() {
# DESC:
# Cleanup and exit from a script
# ARGS:
# $1 (optional) - Exit code (defaults to 0)
# OUTS:
# None
if [[ -d ${SCRIPT_LOCK-} ]]; then
if command rm -rf "${SCRIPT_LOCK}"; then
debug "Removing script lock"
else
warning "Script lock could not be removed. Try manually deleting ${yellow}'${SCRIPT_LOCK}'"
fi
fi
if [[ -n ${TMP_DIR-} && -d ${TMP_DIR-} ]]; then
if [[ ${1-} == 1 && -n "$(ls "${TMP_DIR}")" ]]; then
command rm -r "${TMP_DIR}"
else
command rm -r "${TMP_DIR}"
debug "Removing temp directory"
fi
fi
trap - INT TERM EXIT
exit "${1:-0}"
}
_trapCleanup_() {
# DESC:
# Log errors and cleanup from script when an error is trapped. Called by 'trap'
# ARGS:
# $1: Line number where error was trapped
# $2: Line number in function
# $3: Command executing at the time of the trap
# $4: Names of all shell functions currently in the execution call stack
# $5: Scriptname
# $6: $BASH_SOURCE
# USAGE:
# trap '_trapCleanup_ ${LINENO} ${BASH_LINENO} "${BASH_COMMAND}" "${FUNCNAME[*]}" "${0}" "${BASH_SOURCE[0]}"' EXIT INT TERM SIGINT SIGQUIT SIGTERM ERR
# OUTS:
# Exits script with error code 1
local _line=${1-} # LINENO
local _linecallfunc=${2-}
local _command="${3-}"
local _funcstack="${4-}"
local _script="${5-}"
local _sourced="${6-}"
# Replace the cursor in-case 'tput civis' has been used
tput cnorm
if declare -f "fatal" &>/dev/null && declare -f "_printFuncStack_" &>/dev/null; then
_funcstack="'$(printf "%s" "${_funcstack}" | sed -E 's/ / < /g')'"
if [[ ${_script##*/} == "${_sourced##*/}" ]]; then
fatal "${7-} command: '${_command}' (line: ${_line}) [func: $(_printFuncStack_)]"
else
fatal "${7-} command: '${_command}' (func: ${_funcstack} called at line ${_linecallfunc} of '${_script##*/}') (line: ${_line} of '${_sourced##*/}') "
fi
else
printf "%s\n" "Fatal error trapped. Exiting..."
fi
if declare -f _safeExit_ &>/dev/null; then
_safeExit_ 1
else
exit 1
fi
}
_makeTempDir_() {
# DESC:
# Creates a temp directory to house temporary files
# ARGS:
# $1 (Optional) - First characters/word of directory name
# OUTS:
# Sets $TMP_DIR variable to the path of the temp directory
# USAGE:
# _makeTempDir_ "$(basename "$0")"
[ -d "${TMP_DIR-}" ] && return 0
if [ -n "${1-}" ]; then
TMP_DIR="${TMPDIR:-/tmp/}${1}.${RANDOM}.${RANDOM}.$$"
else
TMP_DIR="${TMPDIR:-/tmp/}$(basename "$0").${RANDOM}.${RANDOM}.${RANDOM}.$$"
fi
(umask 077 && mkdir "${TMP_DIR}") || {
fatal "Could not create temporary directory! Exiting."
}
debug "\$TMP_DIR=${TMP_DIR}"
}
# shellcheck disable=SC2120
_acquireScriptLock_() {
# DESC:
# Acquire script lock to prevent running the same script a second time before the
# first instance exits
# ARGS:
# $1 (optional) - Scope of script execution lock (system or user)
# OUTS:
# exports $SCRIPT_LOCK - Path to the directory indicating we have the script lock
# Exits script if lock cannot be acquired
# NOTE:
# If the lock was acquired it's automatically released in _safeExit_()
local _lockDir
if [[ ${1-} == 'system' ]]; then
_lockDir="${TMPDIR:-/tmp/}$(basename "$0").lock"
else
_lockDir="${TMPDIR:-/tmp/}$(basename "$0").${UID}.lock"
fi
if command mkdir "${_lockDir}" 2>/dev/null; then
readonly SCRIPT_LOCK="${_lockDir}"
debug "Acquired script lock: ${yellow}${SCRIPT_LOCK}${purple}"
else
if declare -f "_safeExit_" &>/dev/null; then
error "Unable to acquire script lock: ${yellow}${_lockDir}${red}"
fatal "If you trust the script isn't running, delete the lock dir"
else
printf "%s\n" "ERROR: Could not acquire script lock. If you trust the script isn't running, delete: ${_lockDir}"
exit 1
fi
fi
}
_setPATH_() {
# DESC:
# Add directories to $PATH so script can find executables
# ARGS:
# $@ - One or more paths
# OPTS:
# -x - Fail if directories are not found
# OUTS:
# 0: Success
# 1: Failure
# Adds items to $PATH
# USAGE:
# _setPATH_ "/usr/local/bin" "${HOME}/bin" "$(npm bin)"
[[ $# == 0 ]] && fatal "Missing required argument to ${FUNCNAME[0]}"
local opt
local OPTIND=1
local _failIfNotFound=false
while getopts ":xX" opt; do
case ${opt} in
x | X) _failIfNotFound=true ;;
*)
{
error "Unrecognized option '${1}' passed to _backupFile_" "${LINENO}"
return 1
}
;;
esac
done
shift $((OPTIND - 1))
local _newPath
for _newPath in "$@"; do
if [ -d "${_newPath}" ]; then
if ! printf "%s" "${PATH}" | grep -Eq "(^|:)${_newPath}($|:)"; then
if PATH="${_newPath}:${PATH}"; then
debug "Added '${_newPath}' to PATH"
else
debug "'${_newPath}' already in PATH"
fi
else
debug "_setPATH_: '${_newPath}' already exists in PATH"
fi
else
debug "_setPATH_: can not find: ${_newPath}"
if [[ ${_failIfNotFound} == true ]]; then
return 1
fi
continue
fi
done
return 0
}
_useGNUutils_() {
# DESC:
# Add GNU utilities to PATH to allow consistent use of sed/grep/tar/etc. on MacOS
# ARGS:
# None
# OUTS:
# 0 if successful
# 1 if unsuccessful
# PATH: Adds GNU utilities to the path
# USAGE:
# # if ! _useGNUUtils_; then exit 1; fi
# NOTES:
# GNU utilities can be added to MacOS using Homebrew
! declare -f "_setPATH_" &>/dev/null && fatal "${FUNCNAME[0]} needs function _setPATH_"
if _setPATH_ \
"/usr/local/opt/gnu-tar/libexec/gnubin" \
"/usr/local/opt/coreutils/libexec/gnubin" \
"/usr/local/opt/gnu-sed/libexec/gnubin" \
"/usr/local/opt/grep/libexec/gnubin" \
"/usr/local/opt/findutils/libexec/gnubin" \
"/opt/homebrew/opt/findutils/libexec/gnubin" \
"/opt/homebrew/opt/gnu-sed/libexec/gnubin" \
"/opt/homebrew/opt/grep/libexec/gnubin" \
"/opt/homebrew/opt/coreutils/libexec/gnubin" \
"/opt/homebrew/opt/gnu-tar/libexec/gnubin"; then
return 0
else
return 1
fi
}
_homebrewPath_() {
# DESC:
# Add homebrew bin dir to PATH
# ARGS:
# None
# OUTS:
# 0 if successful
# 1 if unsuccessful
# PATH: Adds homebrew bin directory to PATH
# USAGE:
# # if ! _homebrewPath_; then exit 1; fi
! declare -f "_setPATH_" &>/dev/null && fatal "${FUNCNAME[0]} needs function _setPATH_"
if _uname=$(command -v uname); then
if "${_uname}" | tr '[:upper:]' '[:lower:]' | grep -q 'darwin'; then
if _setPATH_ "/usr/local/bin" "/opt/homebrew/bin"; then
return 0
else
return 1
fi
fi
else
if _setPATH_ "/usr/local/bin" "/opt/homebrew/bin"; then
return 0
else
return 1
fi
fi
}
_parseOptions_() {
# DESC:
# Iterates through options passed to script and sets variables. Will break -ab into -a -b
# when needed and --foo=bar into --foo bar
# ARGS:
# $@ from command line
# OUTS:
# Sets array 'ARGS' containing all arguments passed to script that were not parsed as options
# USAGE:
# _parseOptions_ "$@"
# Iterate over options
local _optstring=h
declare -a _options
local _c
local i
while (($#)); do
case $1 in
# If option is of type -ab
-[!-]?*)
# Loop over each character starting with the second
for ((i = 1; i < ${#1}; i++)); do
_c=${1:i:1}
_options+=("-${_c}") # Add current char to options
# If option takes a required argument, and it's not the last char make
# the rest of the string its argument
if [[ ${_optstring} == *"${_c}:"* && -n ${1:i+1} ]]; then
_options+=("${1:i+1}")
break
fi
done
;;
# If option is of type --foo=bar
--?*=*) _options+=("${1%%=*}" "${1#*=}") ;;
# add --endopts for --
--) _options+=(--endopts) ;;
# Otherwise, nothing special
*) _options+=("$1") ;;
esac
shift
done
set -- "${_options[@]-}"
unset _options
# Read the options and set stuff
# shellcheck disable=SC2034
while [[ ${1-} == -?* ]]; do
case $1 in
# Custom options
# Common options
-h | --help)
_usage_
_safeExit_
;;
--loglevel)
shift
LOGLEVEL=${1}
;;
--logfile)
shift
LOGFILE="${1}"
;;
-n | --dryrun) DRYRUN=true ;;
-v | --verbose) VERBOSE=true ;;
-q | --quiet) QUIET=true ;;
--force) FORCE=true ;;
--endopts)
shift
break
;;
*)
if declare -f _safeExit_ &>/dev/null; then
fatal "invalid option: $1"
else
printf "%s\n" "ERROR: Invalid option: $1"
exit 1
fi
;;
esac
shift
done
if [[ -z ${*} || ${*} == null ]]; then
ARGS=()
else
ARGS+=("$@") # Store the remaining user input as arguments.
fi
}
_columns_() {
# DESC:
# Prints a two column output from a key/value pair.
# Optionally pass a number of 2 space tabs to indent the output.
# ARGS:
# $1 (required): Key name (Left column text)
# $2 (required): Long value (Right column text. Wraps around if too long)
# $3 (optional): Number of 2 character tabs to indent the command (default 1)
# OPTS:
# -b Bold the left column
# -u Underline the left column
# -r Reverse background and foreground colors
# OUTS:
# stdout: Prints the output in columns
# NOTE:
# Long text or ANSI colors in the first column may create display issues
# USAGE:
# _columns_ "Key" "Long value text" [tab level]
[[ $# -lt 2 ]] && fatal "Missing required argument to ${FUNCNAME[0]}"
local opt
local OPTIND=1
local _style=""
while getopts ":bBuUrR" opt; do
case ${opt} in
b | B) _style="${_style}${bold}" ;;
u | U) _style="${_style}${underline}" ;;
r | R) _style="${_style}${reverse}" ;;
*) fatal "Unrecognized option '${1}' passed to ${FUNCNAME[0]}. Exiting." ;;
esac
done
shift $((OPTIND - 1))
local _key="${1}"
local _value="${2}"
local _tabLevel="${3-}"
local _tabSize=2
local _line
local _rightIndent
local _leftIndent
if [[ -z ${3-} ]]; then
_tabLevel=0
fi
_leftIndent="$((_tabLevel * _tabSize))"
local _leftColumnWidth="$((30 + _leftIndent))"
if [ "$(tput cols)" -gt 180 ]; then
_rightIndent=110
elif [ "$(tput cols)" -gt 160 ]; then
_rightIndent=90
elif [ "$(tput cols)" -gt 130 ]; then
_rightIndent=60
elif [ "$(tput cols)" -gt 120 ]; then
_rightIndent=50
elif [ "$(tput cols)" -gt 110 ]; then
_rightIndent=40
elif [ "$(tput cols)" -gt 100 ]; then
_rightIndent=30
elif [ "$(tput cols)" -gt 90 ]; then
_rightIndent=20
elif [ "$(tput cols)" -gt 80 ]; then
_rightIndent=10
else
_rightIndent=0
fi
local _rightWrapLength=$(($(tput cols) - _leftColumnWidth - _leftIndent - _rightIndent))
local _first_line=0
while read -r _line; do
if [[ ${_first_line} -eq 0 ]]; then
_first_line=1
else
_key=" "
fi
printf "%-${_leftIndent}s${_style}%-${_leftColumnWidth}b${reset} %b\n" "" "${_key}${reset}" "${_line}"
done <<<"$(fold -w${_rightWrapLength} -s <<<"${_value}")"
}
_usage_() {
cat <<USAGE_TEXT
${bold}$(basename "$0") [OPTION]... [FILE]...${reset}
Custom pre-commit hook script. This script is intended to be used as part of the pre-commit pipeline managed within .pre-commit-config.yaml.
${bold}${underline}Options:${reset}
$(_columns_ -b -- '-h, --help' "Display this help and exit" 2)
$(_columns_ -b -- "--loglevel [LEVEL]" "One of: FATAL, ERROR (default), WARN, INFO, NOTICE, DEBUG, ALL, OFF" 2)
$(_columns_ -b -- "--logfile [FILE]" "Full PATH to logfile. (Default is '\${HOME}/logs/$(basename "$0").log')" 2)
$(_columns_ -b -- "-n, --dryrun" "Non-destructive. Makes no permanent changes." 2)
$(_columns_ -b -- "-q, --quiet" "Quiet (no output)" 2)
$(_columns_ -b -- "-v, --verbose" "Output more information. (Items echoed to 'verbose')" 2)
$(_columns_ -b -- "--force" "Skip all user interaction. Implied 'Yes' to all actions." 2)
${bold}${underline}Example Usage:${reset}
${gray}# Run the script and specify log level and log file.${reset}
$(basename "$0") -vn --logfile "/path/to/file.log" --loglevel 'WARN'
USAGE_TEXT
}
# ################################## INITIALIZE AND RUN THE SCRIPT
# (Comment or uncomment the lines below to customize script behavior)
trap '_trapCleanup_ ${LINENO} ${BASH_LINENO} "${BASH_COMMAND}" "${FUNCNAME[*]}" "${0}" "${BASH_SOURCE[0]}"' EXIT INT TERM SIGINT SIGQUIT SIGTERM
# Trap errors in subshells and functions
set -o errtrace
# Exit on error. Append '||true' if you expect an error
set -o errexit
# Use last non-zero exit code in a pipeline
set -o pipefail
# Confirm we have BASH greater than v4
[ "${BASH_VERSINFO:-0}" -ge 4 ] || {
printf "%s\n" "ERROR: BASH_VERSINFO is '${BASH_VERSINFO:-0}'. This script requires BASH v4 or greater."
exit 1
}
# Make `for f in *.txt` work when `*.txt` matches zero files
shopt -s nullglob globstar
# Set IFS to preferred implementation
IFS=$' \n\t'
# Run in debug mode
# set -o xtrace
# Initialize color constants
_setColors_
# Disallow expansion of unset variables
set -o nounset
# Force arguments when invoking the script
# [[ $# -eq 0 ]] && _parseOptions_ "-h"
# Parse arguments passed to script
_parseOptions_ "$@"
# Create a temp directory '$TMP_DIR'
_makeTempDir_ "$(basename "$0")"
# Acquire script lock
# _acquireScriptLock_
# Add Homebrew bin directory to PATH (MacOS)
# _homebrewPath_
# Source GNU utilities from Homebrew (MacOS)
# _useGNUutils_
# Run the main logic script
_mainScript_
# Exit cleanly
_safeExit_

150
scripts/update_dependencies.py Executable file
View File

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

View File

@@ -1,2 +1,2 @@
"""obsidian-metadata version.""" """obsidian-metadata version."""
__version__ = "0.7.0" __version__ = "0.12.1"

View File

@@ -54,7 +54,7 @@ class ConfigQuestions:
class Config: class Config:
"""Representation of a configuration file.""" """Representation of a configuration file."""
def __init__(self, config_path: Path = None, vault_path: Path = None) -> None: def __init__(self, config_path: Path | None = None, vault_path: Path | None = None) -> None:
if vault_path is None: if vault_path is None:
self.config_path: Path = self._validate_config_path(Path(config_path)) self.config_path: Path = self._validate_config_path(Path(config_path))
self.config: dict[str, Any] = self._load_config() self.config: dict[str, Any] = self._load_config()
@@ -77,10 +77,11 @@ class Config:
VaultConfig(vault_name=key, vault_config=self.config[key]) for key in self.config VaultConfig(vault_name=key, vault_config=self.config[key]) for key in self.config
] ]
except TypeError as e: except TypeError as e:
log.error(f"Configuration file is invalid: '{self.config_path}'") log.error(f"Configuration file is invalid: '{self.config_path}'\n{e}")
raise typer.Exit(code=1) from e raise typer.Exit(code=1) from e
log.debug(f"Loaded configuration from '{self.config_path}'") log.debug(f"Loaded configuration from '{self.config_path}'")
log.trace("Configuration:")
log.trace(self.config) log.trace(self.config)
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
@@ -91,10 +92,10 @@ class Config:
def _load_config(self) -> dict[str, Any]: def _load_config(self) -> dict[str, Any]:
"""Load the configuration file.""" """Load the configuration file."""
try: try:
with open(self.config_path, encoding="utf-8") as fp: with self.config_path.open(mode="rt", encoding="utf-8") as fp:
return tomlkit.load(fp) return tomlkit.load(fp)
except tomlkit.exceptions.TOMLKitError as e: except tomlkit.exceptions.TOMLKitError as e:
alerts.error(f"Could not parse '{self.config_path}'") alerts.error(f"Could not parse '{self.config_path}'\n{e}")
raise typer.Exit(code=1) from e raise typer.Exit(code=1) from e
def _validate_config_path(self, config_path: Path | None) -> Path: def _validate_config_path(self, config_path: Path | None) -> Path:
@@ -117,6 +118,8 @@ class Config:
["Vault 1"] # Name of the vault. ["Vault 1"] # Name of the vault.
# Path to your obsidian vault # Path to your obsidian vault
# Note for Windows users: Windows paths must use `\\` as the path separator due to a limitation with how TOML parses strings.
# Example: "C:\\Users\\username\\Documents\\Obsidian"
path = "{vault_path}" path = "{vault_path}"
# Folders within the vault to ignore when indexing metadata # Folders within the vault to ignore when indexing metadata

View File

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

View File

@@ -87,13 +87,16 @@ def info(msg: str) -> None:
console.print(f"INFO | {msg}") console.print(f"INFO | {msg}")
def usage(msg: str, width: int = 80) -> None: def usage(msg: str, width: int | None = None) -> None:
"""Print a usage message without using logging. """Print a usage message without using logging.
Args: Args:
msg: Message to print msg: Message to print
width (optional): Width of the message width (optional): Width of the message
""" """
if width is None:
width = console.width - 15
for _n, line in enumerate(wrap(msg, width=width)): for _n, line in enumerate(wrap(msg, width=width)):
if _n == 0: if _n == 0:
console.print(f"[dim]USAGE | {line}") console.print(f"[dim]USAGE | {line}")
@@ -121,14 +124,13 @@ def dim(msg: str) -> None:
def _log_formatter(record: dict) -> str: def _log_formatter(record: dict) -> str:
"""Create custom log formatter based on the log level. This effects the logs sent to stdout/stderr but not the log file.""" """Create custom log formatter based on the log level. This effects the logs sent to stdout/stderr but not the log file."""
if ( if record["level"].name in ("INFO", "SUCCESS", "WARNING"):
record["level"].name == "INFO" return "<level><normal>{level: <8} | {message}</normal></level>\n{exception}"
or record["level"].name == "SUCCESS"
or record["level"].name == "WARNING"
):
return "<level>{level: <8}</level> | <level>{message}</level>\n{exception}"
return "<level>{level: <8}</level> | <level>{message}</level> <fg #c5c5c5>({name}:{function}:{line})</fg #c5c5c5>\n{exception}" if record["level"].name in ("TRACE", "DEBUG"):
return "<level><normal>{level: <8} | {message}</normal></level> <fg #c5c5c5>({name}:{function}:{line})</fg #c5c5c5>\n{exception}"
return "<level>{level: <8} | {message}</level> <fg #c5c5c5>({name}:{function}:{line})</fg #c5c5c5>\n{exception}"
@rich.repr.auto @rich.repr.auto
@@ -172,8 +174,7 @@ class LoggerManager:
self.log_level = log_level self.log_level = log_level
if self.log_file == Path("/logs") and self.log_to_file: # pragma: no cover if self.log_file == Path("/logs") and self.log_to_file: # pragma: no cover
console.print("No log file specified") raise typer.BadParameter("No log file specified")
raise typer.Exit(1)
if self.verbosity >= VerboseLevel.TRACE.value: if self.verbosity >= VerboseLevel.TRACE.value:
logger.remove() logger.remove()

View File

@@ -2,3 +2,4 @@
from rich.console import Console from rich.console import Console
console = Console() console = Console()
console_no_markup = Console(markup=False)

View File

@@ -1,6 +1,9 @@
"""Utility functions.""" """Utility functions."""
import copy
import csv
import re import re
from os import name, system from os import name, system
from pathlib import Path
from typing import Any from typing import Any
import typer import typer
@@ -18,24 +21,26 @@ def clean_dictionary(dictionary: dict[str, Any]) -> dict[str, Any]:
Returns: Returns:
dict: Cleaned dictionary dict: Cleaned dictionary
""" """
new_dict = {key.strip(): value for key, value in dictionary.items()} new_dict = copy.deepcopy(dictionary)
new_dict = {key.strip("*[]#"): value for key, value in new_dict.items()} new_dict = {key.strip("*[]# "): value for key, value in new_dict.items()}
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)] if isinstance(value, list):
new_dict[key] = [s.strip("*[]# ") for s in value if isinstance(value, list)]
elif isinstance(value, str):
new_dict[key] = value.strip("*[]# ")
return new_dict return new_dict
def clear_screen() -> None: # pragma: no cover def clear_screen() -> None: # pragma: no cover
"""Clear the screen.""" """Clear the screen."""
# for windows _ = system("cls") if name == "nt" else system("clear") # noqa: S605, S607
_ = system("cls") if name == "nt" else system("clear")
def dict_contains( def dict_contains(
dictionary: dict[str, list[str]], key: str, value: str = None, is_regex: bool = False dictionary: dict[str, list[str]], key: str, value: str | None = None, is_regex: bool = False
) -> bool: ) -> bool:
"""Check if a dictionary contains a key or if a specified key contains a value. """Check if a dictionary contains a key or if a key contains a value.
Args: Args:
dictionary (dict): Dictionary to check dictionary (dict): Dictionary to check
@@ -44,7 +49,7 @@ def dict_contains(
is_regex (bool, optional): Whether the key is a regex. Defaults to False. is_regex (bool, optional): Whether the key is a regex. Defaults to False.
Returns: Returns:
bool: Whether the dictionary contains the key bool: Whether the dictionary contains the key or value
""" """
if value is None: if value is None:
if is_regex: if is_regex:
@@ -52,56 +57,67 @@ def dict_contains(
return key in dictionary return key in dictionary
if is_regex: if is_regex:
found_keys = []
for _key in dictionary: for _key in dictionary:
if re.search(key, str(_key)): if re.search(key, str(_key)) and any(re.search(value, _v) for _v in dictionary[_key]):
found_keys.append( return True
any(re.search(value, _v) for _v in dictionary[_key]),
) return False
return any(found_keys)
return key in dictionary and value in dictionary[key] return key in dictionary and value in dictionary[key]
def dict_values_to_lists_strings( def dict_keys_to_lower(dictionary: dict) -> dict:
dictionary: dict, """Convert all keys in a dictionary to lowercase.
strip_null_values: bool = False,
) -> dict:
"""Convert all values in a dictionary to lists of strings.
Args: Args:
dictionary (dict): Dictionary to convert dictionary (dict): Dictionary to convert
strip_null_values (bool): Whether to strip null values
Returns: Returns:
dict: Dictionary with all values converted to lists of strings dict: Dictionary with all keys converted to lowercase
{key: sorted(new_dict[key]) for key in sorted(new_dict)}
""" """
new_dict = {} return {key.lower(): value for key, value in dictionary.items()}
if strip_null_values:
for key, value in dictionary.items():
if isinstance(value, list):
new_dict[key] = sorted([str(item) for item in value if item is not None])
elif isinstance(value, dict):
new_dict[key] = dict_values_to_lists_strings(value) # type: ignore[assignment]
elif value is None or value == "None" or value == "":
new_dict[key] = []
else:
new_dict[key] = [str(value)]
return new_dict def delete_from_dict( # noqa: C901
dictionary: dict, key: str, value: str | None = None, is_regex: bool = False
) -> dict:
"""Delete a key or a value from a dictionary.
for key, value in dictionary.items(): Args:
if isinstance(value, list): dictionary (dict): Dictionary to delete from
new_dict[key] = sorted([str(item) for item in value]) is_regex (bool, optional): Whether the key is a regex. Defaults to False.
elif isinstance(value, dict): key (str): Key to delete
new_dict[key] = dict_values_to_lists_strings(value) # type: ignore[assignment] value (str, optional): Value to delete. Defaults to None.
else:
new_dict[key] = [str(value)]
return new_dict Returns:
dict: Dictionary without the key
"""
dictionary = copy.deepcopy(dictionary)
if value is None:
if is_regex:
return {k: v for k, v in dictionary.items() if not re.search(key, str(k))}
return {k: v for k, v in dictionary.items() if k != key}
if is_regex:
keys_to_delete = []
for _key in dictionary:
if re.search(key, str(_key)):
if isinstance(dictionary[_key], list):
dictionary[_key] = [v for v in dictionary[_key] if not re.search(value, v)]
elif isinstance(dictionary[_key], str) and re.search(value, dictionary[_key]):
keys_to_delete.append(_key)
for key in keys_to_delete:
dictionary.pop(key)
elif key in dictionary and isinstance(dictionary[key], list):
dictionary[key] = [v for v in dictionary[key] if v != value]
elif key in dictionary and dictionary[key] == value:
dictionary.pop(key)
return dictionary
def docstring_parameter(*sub: Any) -> Any: def docstring_parameter(*sub: Any) -> Any:
@@ -135,55 +151,106 @@ def merge_dictionaries(dict1: dict, dict2: dict) -> dict:
Returns: Returns:
dict: Merged dictionary. dict: Merged dictionary.
""" """
for k, v in dict2.items(): d1 = copy.deepcopy(dict1)
if k in dict1: d2 = copy.deepcopy(dict2)
if isinstance(v, list):
dict1[k].extend(v) for _key in d1:
if not isinstance(d1[_key], list):
raise TypeError(f"Key {_key} is not a list.")
for _key in d2:
if not isinstance(d2[_key], list):
raise TypeError(f"Key {_key} is not a list.")
for k, v in d2.items():
if k in d1:
d1[k].extend(v)
d1[k] = sorted(set(d1[k]))
else: else:
dict1[k] = v d1[k] = sorted(set(v))
for k, v in dict1.items(): return dict(sorted(d1.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( def rename_in_dict(
text: str, dictionary: dict[str, list[str]], key: str, value_1: str, value_2: str | None = None
strip_codeblocks: bool = False, ) -> dict:
strip_inlinecode: bool = False, """Rename a key or a value in a dictionary who's values are lists of strings.
strip_frontmatter: bool = False,
) -> str:
"""Strip markdown sections from text.
Args: Args:
text (str): Text to remove code blocks from dictionary (dict): Dictionary to rename in.
strip_codeblocks (bool, optional): Strip code blocks. Defaults to False. key (str): Key to check.
strip_inlinecode (bool, optional): Strip inline code. Defaults to False. value_1 (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key
strip_frontmatter (bool, optional): Strip frontmatter. Defaults to False. value_2 (str, Optional): New value.
Returns: Returns:
str: Text without code blocks dict: Dictionary with renamed key or value
""" """
if strip_codeblocks: dictionary = copy.deepcopy(dictionary)
text = re.sub(r"`{3}.*?`{3}", "", text, flags=re.DOTALL)
if strip_inlinecode: if value_2 is None:
text = re.sub(r"`.*?`", "", text) if key in dictionary and value_1 not in dictionary:
dictionary[value_1] = dictionary.pop(key)
elif key in dictionary and value_1 in dictionary[key]:
dictionary[key] = sorted({value_2 if x == value_1 else x for x in dictionary[key]})
if strip_frontmatter: return dictionary
text = re.sub(r"^\s*---.*?---", "", text, flags=re.DOTALL)
return text # noqa: RET504
def validate_csv_bulk_imports( # noqa: C901
csv_path: Path, note_paths: list
) -> dict[str, list[dict[str, str]]]:
"""Validate the bulk import CSV file.
Args:
csv_path (dict): Dictionary to validate
note_paths (list): List of paths to all notes in vault
Returns:
dict: Validated dictionary
"""
csv_dict: dict[str, Any] = {}
with csv_path.expanduser().open("r") as csv_file:
csv_reader = csv.DictReader(csv_file, delimiter=",")
row_num = 0
for row in csv_reader:
if row_num == 0:
if "path" not in row:
raise typer.BadParameter("Missing 'path' column in CSV file")
if "type" not in row:
raise typer.BadParameter("Missing 'type' column in CSV file")
if "key" not in row:
raise typer.BadParameter("Missing 'key' column in CSV file")
if "value" not in row:
raise typer.BadParameter("Missing 'value' column in CSV file")
row_num += 1
if row_num > 0 and row["type"] not in ["tag", "frontmatter", "inline_metadata"]:
raise typer.BadParameter(
f"Invalid type '{row['type']}' in CSV file. Must be one of 'tag', 'frontmatter', 'inline_metadata'"
)
if row["path"] not in csv_dict:
csv_dict[row["path"]] = []
csv_dict[row["path"]].append(
{"type": row["type"], "key": row["key"], "value": row["value"]}
)
if row_num in [0, 1]:
raise typer.BadParameter("Empty CSV file")
paths_to_remove = [x for x in csv_dict if x not in note_paths]
for _path in paths_to_remove:
raise typer.BadParameter(
f"'{_path}' in CSV does not exist in vault. Ensure all paths are relative to the vault root."
)
return csv_dict
def version_callback(value: bool) -> None: def version_callback(value: bool) -> None:
"""Print version and exit.""" """Print version and exit."""
if value: if value:
console.print(f"{__package__.split('.')[0]}: v{__version__}") console.print(f"{__package__.split('.')[0]}: v{__version__}")
raise typer.Exit() raise typer.Exit(0)

View File

@@ -34,14 +34,28 @@ def main(
), ),
export_csv: Path = typer.Option( export_csv: Path = typer.Option(
None, None,
help="Exports all metadata to a specified CSV file and exits. (Will overwrite any existing file)", help="Exports all metadata to a specified CSV file and exits.",
show_default=False, show_default=False,
dir_okay=False, dir_okay=False,
file_okay=True, file_okay=True,
), ),
export_json: Path = typer.Option( export_json: Path = typer.Option(
None, None,
help="Exports all metadata to a specified JSON file and exits. (Will overwrite any existing file)", help="Exports all metadata to a specified JSON file and exits.",
show_default=False,
dir_okay=False,
file_okay=True,
),
export_template: Path = typer.Option(
None,
help="Exports all notes and their metadata to a specified CSV file and exits. Use to create a template for batch updates.",
show_default=False,
dir_okay=False,
file_okay=True,
),
import_csv: Path = typer.Option(
None,
help="Import a CSV file with bulk updates to metadata.",
show_default=False, show_default=False,
dir_okay=False, dir_okay=False,
file_okay=True, file_okay=True,
@@ -79,7 +93,7 @@ def main(
help="""Set verbosity level (0=WARN, 1=INFO, 2=DEBUG, 3=TRACE)""", help="""Set verbosity level (0=WARN, 1=INFO, 2=DEBUG, 3=TRACE)""",
count=True, count=True,
), ),
version: Optional[bool] = typer.Option( version: Optional[bool] = typer.Option( # noqa: ARG001
None, "--version", help="Print version and exit", callback=version_callback, is_eager=True None, "--version", help="Print version and exit", callback=version_callback, is_eager=True
), ),
) -> None: ) -> None:
@@ -91,65 +105,7 @@ def main(
[bold underline]Configuration:[/] [bold underline]Configuration:[/]
Configuration is specified in a configuration file. On First run, this file will be created at [tan]~/.{0}.env[/]. Any options specified on the command line will override the configuration file. Configuration is specified in a configuration file. On First run, this file will be created at [tan]~/.{0}.env[/]. Any options specified on the command line will override the configuration file.
[bold underline]Usage:[/] Full usage information is available at https://github.com/natelandau/obsidian-metadata
[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 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 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
""" """
# Instantiate logger # Instantiate logger
@@ -176,7 +132,7 @@ def main(
config: Config = Config(config_path=config_file, vault_path=vault_path) config: Config = Config(config_path=config_file, vault_path=vault_path)
if len(config.vaults) == 0: if len(config.vaults) == 0:
typer.echo("No vaults configured. Exiting.") typer.echo("No vaults configured. Exiting.")
raise typer.Exit(1) raise typer.BadParameter("No vaults configured. Exiting.")
if len(config.vaults) == 1: if len(config.vaults) == 1:
application = Application(dry_run=dry_run, config=config.vaults[0]) application = Application(dry_run=dry_run, config=config.vaults[0])
@@ -200,6 +156,14 @@ def main(
path = Path(export_json).expanduser().resolve() path = Path(export_json).expanduser().resolve()
application.noninteractive_export_csv(path) application.noninteractive_export_csv(path)
raise typer.Exit(code=0) raise typer.Exit(code=0)
if export_template is not None:
path = Path(export_template).expanduser().resolve()
application.noninteractive_export_template(path)
raise typer.Exit(code=0)
if import_csv is not None:
path = Path(import_csv).expanduser().resolve()
application.noninteractive_bulk_import(path)
raise typer.Exit(code=0)
application.application_main() application.application_main()

View File

@@ -2,15 +2,9 @@
from obsidian_metadata.models.enums import ( from obsidian_metadata.models.enums import (
InsertLocation, InsertLocation,
MetadataType, MetadataType,
Wrapping,
) )
from obsidian_metadata.models.metadata import InlineField, dict_to_yaml
from obsidian_metadata.models.patterns import Patterns # isort: skip
from obsidian_metadata.models.metadata import (
Frontmatter,
InlineMetadata,
InlineTags,
VaultMetadata,
)
from obsidian_metadata.models.notes import Note from obsidian_metadata.models.notes import Note
from obsidian_metadata.models.vault import Vault, VaultFilter from obsidian_metadata.models.vault import Vault, VaultFilter
@@ -18,15 +12,13 @@ from obsidian_metadata.models.application import Application # isort: skip
__all__ = [ __all__ = [
"Application", "Application",
"Frontmatter", "dict_to_yaml",
"InlineMetadata", "InlineField",
"InlineTags",
"InsertLocation", "InsertLocation",
"LoggerManager", "LoggerManager",
"MetadataType", "MetadataType",
"Note", "Note",
"Patterns",
"Vault", "Vault",
"VaultFilter", "VaultFilter",
"VaultMetadata", "Wrapping",
] ]

View File

@@ -10,9 +10,9 @@ from rich import box
from rich.table import Table from rich.table import Table
from obsidian_metadata._config import VaultConfig from obsidian_metadata._config import VaultConfig
from obsidian_metadata._utils import alerts from obsidian_metadata._utils import alerts, validate_csv_bulk_imports
from obsidian_metadata._utils.console import console from obsidian_metadata._utils.console import console
from obsidian_metadata.models import Vault, VaultFilter from obsidian_metadata.models import InsertLocation, Vault, VaultFilter
from obsidian_metadata.models.enums import MetadataType from obsidian_metadata.models.enums import MetadataType
from obsidian_metadata.models.questions import Questions from obsidian_metadata.models.questions import Questions
@@ -53,8 +53,12 @@ class Application:
match self.questions.ask_application_main(): match self.questions.ask_application_main():
case "vault_actions": case "vault_actions":
self.application_vault() self.application_vault()
case "export_metadata":
self.application_export_metadata()
case "inspect_metadata": case "inspect_metadata":
self.application_inspect_metadata() self.application_inspect_metadata()
case "import_from_csv":
self.application_import_csv()
case "filter_notes": case "filter_notes":
self.application_filter() self.application_filter()
case "add_metadata": case "add_metadata":
@@ -63,8 +67,8 @@ class Application:
self.application_rename_metadata() self.application_rename_metadata()
case "delete_metadata": case "delete_metadata":
self.application_delete_metadata() self.application_delete_metadata()
case "transpose_metadata": case "reorganize_metadata":
self.application_transpose_metadata() self.application_reorganize_metadata()
case "review_changes": case "review_changes":
self.review_changes() self.review_changes()
case "commit_changes": case "commit_changes":
@@ -73,7 +77,6 @@ class Application:
break break
console.print("Done!") console.print("Done!")
return
def application_add_metadata(self) -> None: def application_add_metadata(self) -> None:
"""Add metadata.""" """Add metadata."""
@@ -81,8 +84,8 @@ class Application:
"Add new metadata to your vault. Currently only supports adding to the frontmatter of a note." "Add new metadata to your vault. Currently only supports adding to the frontmatter of a note."
) )
area = self.questions.ask_area() meta_type = self.questions.ask_meta_type()
match area: match meta_type:
case MetadataType.FRONTMATTER | MetadataType.INLINE: case MetadataType.FRONTMATTER | MetadataType.INLINE:
key = self.questions.ask_new_key(question="Enter the key for the new metadata") key = self.questions.ask_new_key(question="Enter the key for the new metadata")
if key is None: # pragma: no cover if key is None: # pragma: no cover
@@ -95,7 +98,7 @@ class Application:
return return
num_changed = self.vault.add_metadata( num_changed = self.vault.add_metadata(
area=area, key=key, value=value, location=self.vault.insert_location meta_type=meta_type, key=key, value=value, location=self.vault.insert_location
) )
if num_changed == 0: # pragma: no cover if num_changed == 0: # pragma: no cover
alerts.warning("No notes were changed") alerts.warning("No notes were changed")
@@ -109,7 +112,7 @@ class Application:
return return
num_changed = self.vault.add_metadata( num_changed = self.vault.add_metadata(
area=area, value=tag, location=self.vault.insert_location meta_type=meta_type, value=tag, location=self.vault.insert_location
) )
if num_changed == 0: # pragma: no cover if num_changed == 0: # pragma: no cover
@@ -125,7 +128,8 @@ class Application:
alerts.usage("Delete either a key and all associated values, or a specific value.") alerts.usage("Delete either a key and all associated values, or a specific value.")
choices = [ choices = [
{"name": "Delete inline tag", "value": "delete_inline_tag"}, questionary.Separator(),
{"name": "Delete inline tag", "value": "delete_tag"},
{"name": "Delete key", "value": "delete_key"}, {"name": "Delete key", "value": "delete_key"},
{"name": "Delete value", "value": "delete_value"}, {"name": "Delete value", "value": "delete_value"},
questionary.Separator(), questionary.Separator(),
@@ -138,8 +142,8 @@ class Application:
self.delete_key() self.delete_key()
case "delete_value": case "delete_value":
self.delete_value() self.delete_value()
case "delete_inline_tag": case "delete_tag":
self.delete_inline_tag() self.delete_tag()
case _: # pragma: no cover case _: # pragma: no cover
return return
@@ -148,7 +152,8 @@ class Application:
alerts.usage("Select the type of metadata to rename.") alerts.usage("Select the type of metadata to rename.")
choices = [ choices = [
{"name": "Rename inline tag", "value": "rename_inline_tag"}, questionary.Separator(),
{"name": "Rename inline tag", "value": "rename_tag"},
{"name": "Rename key", "value": "rename_key"}, {"name": "Rename key", "value": "rename_key"},
{"name": "Rename value", "value": "rename_value"}, {"name": "Rename value", "value": "rename_value"},
questionary.Separator(), questionary.Separator(),
@@ -161,8 +166,8 @@ class Application:
self.rename_key() self.rename_key()
case "rename_value": case "rename_value":
self.rename_value() self.rename_value()
case "rename_inline_tag": case "rename_tag":
self.rename_inline_tag() self.rename_tag()
case _: # pragma: no cover case _: # pragma: no cover
return return
@@ -171,6 +176,7 @@ class Application:
alerts.usage("Limit the scope of notes to be processed with one or more filters.") alerts.usage("Limit the scope of notes to be processed with one or more filters.")
choices = [ choices = [
questionary.Separator(),
{"name": "Apply new regex path filter", "value": "apply_path_filter"}, {"name": "Apply new regex path filter", "value": "apply_path_filter"},
{"name": "Apply new metadata filter", "value": "apply_metadata_filter"}, {"name": "Apply new metadata filter", "value": "apply_metadata_filter"},
{"name": "Apply new in-text tag filter", "value": "apply_tag_filter"}, {"name": "Apply new in-text tag filter", "value": "apply_tag_filter"},
@@ -183,7 +189,7 @@ class Application:
match self.questions.ask_selection(choices=choices, question="Select an action"): match self.questions.ask_selection(choices=choices, question="Select an action"):
case "apply_path_filter": case "apply_path_filter":
path = self.questions.ask_filter_path() path = self.questions.ask_filter_path()
if path is None or path == "": # pragma: no cover if path is None or not path: # pragma: no cover
return return
self.filters.append(VaultFilter(path_filter=path)) self.filters.append(VaultFilter(path_filter=path))
@@ -200,15 +206,15 @@ class Application:
) )
if value is None: # pragma: no cover if value is None: # pragma: no cover
return return
if value == "": if not value:
self.filters.append(VaultFilter(key_filter=key)) self.filters.append(VaultFilter(key_filter=key))
else: else:
self.filters.append(VaultFilter(key_filter=key, value_filter=value)) self.filters.append(VaultFilter(key_filter=key, value_filter=value))
self._load_vault() self._load_vault()
case "apply_tag_filter": case "apply_tag_filter":
tag = self.questions.ask_existing_inline_tag() tag = self.questions.ask_existing_tag()
if tag is None or tag == "": if tag is None or not tag:
return return
self.filters.append(VaultFilter(tag_filter=tag)) self.filters.append(VaultFilter(tag_filter=tag))
@@ -277,6 +283,76 @@ class Application:
case _: case _:
return return
def application_import_csv(self) -> None:
"""Import CSV for bulk changes to metadata."""
alerts.usage(
"Import CSV to make build changes to metadata. The CSV must have the following columns: path, type, key, value. Where type is one of 'frontmatter', 'inline_metadata', or 'tag'. Note: this will not create new notes."
)
path = self.questions.ask_path(question="Enter path to a CSV file", valid_file=True)
if path is None:
return
csv_path = Path(path).expanduser()
if "csv" not in csv_path.suffix.lower():
alerts.error("File must be a CSV file")
return
note_paths = [
str(n.note_path.relative_to(self.vault.vault_path)) for n in self.vault.all_notes
]
dict_from_csv = validate_csv_bulk_imports(csv_path, note_paths)
num_changed = self.vault.update_from_dict(dict_from_csv)
if num_changed == 0:
alerts.warning("No notes were changed")
return
alerts.success(f"Rewrote metadata for {num_changed} notes.")
def application_export_metadata(self) -> None:
"""Export metadata to various formats."""
alerts.usage(
"Export the metadata in your vault. Note, uncommitted changes will be reflected in these files. The notes csv export can be used as template for importing bulk changes"
)
choices = [
questionary.Separator(),
{"name": "Metadata by type to CSV", "value": "export_csv"},
{"name": "Metadata by type to JSON", "value": "export_json"},
{
"name": "Metadata by note to CSV [Bulk import template]",
"value": "export_notes_csv",
},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
while True:
match self.questions.ask_selection(choices=choices, question="Export format"):
case "export_csv":
path = self.questions.ask_path(question="Enter a path for the CSV file")
if path is None:
return
self.vault.export_metadata(path=path, export_format="csv")
alerts.success(f"CSV written to {path}")
case "export_json":
path = self.questions.ask_path(question="Enter a path for the JSON file")
if path is None:
return
self.vault.export_metadata(path=path, export_format="json")
alerts.success(f"JSON written to {path}")
case "export_notes_csv":
path = self.questions.ask_path(question="Enter a path for the CSV file")
if path is None:
return
self.vault.export_notes_to_csv(path=path)
alerts.success(f"CSV written to {path}")
return
case _:
return
def application_inspect_metadata(self) -> None: def application_inspect_metadata(self) -> None:
"""View metadata.""" """View metadata."""
alerts.usage( alerts.usage(
@@ -284,61 +360,63 @@ class Application:
) )
choices = [ choices = [
questionary.Separator(),
{"name": "View all frontmatter", "value": "all_frontmatter"}, {"name": "View all frontmatter", "value": "all_frontmatter"},
{"name": "View all inline metadata", "value": "all_inline"}, {"name": "View all inline metadata", "value": "all_inline"},
{"name": "View all inline tags", "value": "all_tags"}, {"name": "View all inline tags", "value": "all_tags"},
{"name": "View all keys", "value": "all_keys"}, {"name": "View all keys", "value": "all_keys"},
{"name": "View all metadata", "value": "all_metadata"}, {"name": "View all metadata", "value": "all_metadata"},
questionary.Separator(), 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"}, {"name": "Back", "value": "back"},
] ]
while True: while True:
match self.questions.ask_selection(choices=choices, question="Select a vault action"): match self.questions.ask_selection(choices=choices, question="Select an action"):
case "all_metadata": case "all_metadata":
console.print("") console.print("")
self.vault.metadata.print_metadata(area=MetadataType.ALL) # TODO: Add a way to print metadata
self.vault.print_metadata(meta_type=MetadataType.ALL)
console.print("") console.print("")
case "all_frontmatter": case "all_frontmatter":
console.print("") console.print("")
self.vault.metadata.print_metadata(area=MetadataType.FRONTMATTER) self.vault.print_metadata(meta_type=MetadataType.FRONTMATTER)
console.print("") console.print("")
case "all_inline": case "all_inline":
console.print("") console.print("")
self.vault.metadata.print_metadata(area=MetadataType.INLINE) self.vault.print_metadata(meta_type=MetadataType.INLINE)
console.print("") console.print("")
case "all_keys": case "all_keys":
console.print("") console.print("")
self.vault.metadata.print_metadata(area=MetadataType.KEYS) self.vault.print_metadata(meta_type=MetadataType.KEYS)
console.print("") console.print("")
case "all_tags": case "all_tags":
console.print("") console.print("")
self.vault.metadata.print_metadata(area=MetadataType.TAGS) self.vault.print_metadata(meta_type=MetadataType.TAGS)
console.print("") 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 _: case _:
return return
def application_transpose_metadata(self) -> None: def application_reorganize_metadata(self) -> None:
"""Transpose metadata.""" """Reorganize metadata.
alerts.usage("Transpose metadata from frontmatter to inline or vice versa.")
This portion of the application deals with moving metadata between types (inline to frontmatter, etc.) and moving the location of inline metadata within a note.
"""
alerts.usage("Move metadata within notes.")
alerts.usage(" 1. Transpose frontmatter to inline or vice versa.")
alerts.usage(" 2. Move the location of inline metadata within a note.")
choices = [ choices = [
questionary.Separator(),
{"name": "Move inline metadata to top of note", "value": "move_to_top"},
{
"name": "Move inline metadata beneath the first header",
"value": "move_to_after_header",
},
{"name": "Move inline metadata to bottom of the note", "value": "move_to_bottom"},
{"name": "Transpose frontmatter to inline", "value": "frontmatter_to_inline"}, {"name": "Transpose frontmatter to inline", "value": "frontmatter_to_inline"},
{"name": "Transpose inline to frontmatter", "value": "inline_to_frontmatter"}, {"name": "Transpose inline to frontmatter", "value": "inline_to_frontmatter"},
questionary.Separator(),
{"name": "Back", "value": "back"},
] ]
match self.questions.ask_selection( match self.questions.ask_selection(
choices=choices, question="Select metadata to transpose" choices=choices, question="Select metadata to transpose"
@@ -347,6 +425,12 @@ class Application:
self.transpose_metadata(begin=MetadataType.FRONTMATTER, end=MetadataType.INLINE) self.transpose_metadata(begin=MetadataType.FRONTMATTER, end=MetadataType.INLINE)
case "inline_to_frontmatter": case "inline_to_frontmatter":
self.transpose_metadata(begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER) self.transpose_metadata(begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER)
case "move_to_top":
self.move_inline_metadata(location=InsertLocation.TOP)
case "move_to_after_header":
self.move_inline_metadata(location=InsertLocation.AFTER_TITLE)
case "move_to_bottom":
self.move_inline_metadata(location=InsertLocation.BOTTOM)
case _: # pragma: no cover case _: # pragma: no cover
return return
@@ -355,6 +439,7 @@ class Application:
alerts.usage("Create or delete a backup of your vault.") alerts.usage("Create or delete a backup of your vault.")
choices = [ choices = [
questionary.Separator(),
{"name": "Backup vault", "value": "backup_vault"}, {"name": "Backup vault", "value": "backup_vault"},
{"name": "Delete vault backup", "value": "delete_backup"}, {"name": "Delete vault backup", "value": "delete_backup"},
questionary.Separator(), questionary.Separator(),
@@ -398,11 +483,11 @@ class Application:
return True return True
def delete_inline_tag(self) -> None: def delete_tag(self) -> None:
"""Delete an inline tag.""" """Delete an inline tag."""
tag = self.questions.ask_existing_inline_tag(question="Which tag would you like to delete?") tag = self.questions.ask_existing_tag(question="Which tag would you like to delete?")
num_changed = self.vault.delete_inline_tag(tag) num_changed = self.vault.delete_tag(tag)
if num_changed == 0: if num_changed == 0:
alerts.warning("No notes were changed") alerts.warning("No notes were changed")
return return
@@ -418,9 +503,11 @@ class Application:
if key_to_delete is None: # pragma: no cover if key_to_delete is None: # pragma: no cover
return return
num_changed = self.vault.delete_metadata(key_to_delete) num_changed = self.vault.delete_metadata(
key=key_to_delete, meta_type=MetadataType.ALL, is_regex=True
)
if num_changed == 0: if num_changed == 0:
alerts.warning(f"No notes found with a key matching: [reverse]{key_to_delete}[/]") alerts.warning(f"No notes found with a key matching regex: [reverse]{key_to_delete}[/]")
return return
alerts.success( alerts.success(
@@ -440,7 +527,9 @@ class Application:
if value is None: # pragma: no cover if value is None: # pragma: no cover
return return
num_changed = self.vault.delete_metadata(key, value) num_changed = self.vault.delete_metadata(
key=key, value=value, meta_type=MetadataType.ALL, is_regex=True
)
if num_changed == 0: if num_changed == 0:
alerts.warning(f"No notes found matching: {key}: {value}") alerts.warning(f"No notes found matching: {key}: {value}")
return return
@@ -451,10 +540,54 @@ class Application:
return return
def move_inline_metadata(self, location: InsertLocation) -> None:
"""Move inline metadata to the selected location."""
num_changed = self.vault.move_inline_metadata(location)
if num_changed == 0:
alerts.warning("No notes were changed")
return
alerts.success(f"Moved inline metadata to {location.value} in {num_changed} notes")
def noninteractive_bulk_import(self, path: Path) -> None:
"""Bulk update metadata from a CSV from the command line.
Args:
path: Path to the CSV file containing the metadata to update.
"""
self._load_vault()
note_paths = [
str(n.note_path.relative_to(self.vault.vault_path)) for n in self.vault.all_notes
]
dict_from_csv = validate_csv_bulk_imports(path, note_paths)
num_changed = self.vault.update_from_dict(dict_from_csv)
if num_changed == 0:
alerts.warning("No notes were changed")
return
alerts.success(f"{num_changed} notes specified in '{path}'")
alerts.info("Review changes and commit.")
while True:
self.vault.info()
match self.questions.ask_application_main():
case "vault_actions":
self.application_vault()
case "inspect_metadata":
self.application_inspect_metadata()
case "review_changes":
self.review_changes()
case "commit_changes":
self.commit_changes()
case _:
break
console.print("Done!")
def noninteractive_export_csv(self, path: Path) -> None: def noninteractive_export_csv(self, path: Path) -> None:
"""Export the vault metadata to CSV.""" """Export the vault metadata to CSV."""
self._load_vault() self._load_vault()
self.vault.export_metadata(export_format="json", path=str(path)) self.vault.export_metadata(export_format="csv", path=str(path))
alerts.success(f"Exported metadata to {path}") alerts.success(f"Exported metadata to {path}")
def noninteractive_export_json(self, path: Path) -> None: def noninteractive_export_json(self, path: Path) -> None:
@@ -463,6 +596,16 @@ class Application:
self.vault.export_metadata(export_format="json", path=str(path)) self.vault.export_metadata(export_format="json", path=str(path))
alerts.success(f"Exported metadata to {path}") alerts.success(f"Exported metadata to {path}")
def noninteractive_export_template(self, path: Path) -> None:
"""Export the vault metadata to CSV."""
self._load_vault()
with console.status(
"Preparing export... [dim](Can take a while for large vaults)[/]",
spinner="bouncingBall",
):
self.vault.export_notes_to_csv(path=str(path))
alerts.success(f"Exported metadata to {path}")
def rename_key(self) -> None: def rename_key(self) -> None:
"""Rename a key in the vault.""" """Rename a key in the vault."""
original_key = self.questions.ask_existing_key( original_key = self.questions.ask_existing_key(
@@ -484,9 +627,9 @@ class Application:
f"Renamed [reverse]{original_key}[/] to [reverse]{new_key}[/] in {num_changed} notes" f"Renamed [reverse]{original_key}[/] to [reverse]{new_key}[/] in {num_changed} notes"
) )
def rename_inline_tag(self) -> None: def rename_tag(self) -> None:
"""Rename an inline tag.""" """Rename an inline tag."""
original_tag = self.questions.ask_existing_inline_tag(question="Which tag to rename?") original_tag = self.questions.ask_existing_tag(question="Which tag to rename?")
if original_tag is None: # pragma: no cover if original_tag is None: # pragma: no cover
return return
@@ -494,7 +637,7 @@ class Application:
if new_tag is None: # pragma: no cover if new_tag is None: # pragma: no cover
return return
num_changed = self.vault.rename_inline_tag(original_tag, new_tag) num_changed = self.vault.rename_tag(original_tag, new_tag)
if num_changed == 0: if num_changed == 0:
alerts.warning("No notes were changed") alerts.warning("No notes were changed")
return return
@@ -536,6 +679,7 @@ class Application:
alerts.info(f"Found {len(changed_notes)} changed notes in the vault") alerts.info(f"Found {len(changed_notes)} changed notes in the vault")
choices: list[dict[str, Any] | questionary.Separator] = [] choices: list[dict[str, Any] | questionary.Separator] = []
choices.append(questionary.Separator())
for n, note in enumerate(changed_notes, start=1): for n, note in enumerate(changed_notes, start=1):
_selection = { _selection = {
"name": f"{n}: {note.note_path.relative_to(self.vault.vault_path)}", "name": f"{n}: {note.note_path.relative_to(self.vault.vault_path)}",
@@ -566,6 +710,7 @@ class Application:
{"name": f"Transpose all {begin.value} to {end.value}", "value": "transpose_all"}, {"name": f"Transpose all {begin.value} to {end.value}", "value": "transpose_all"},
{"name": "Transpose a key", "value": "transpose_key"}, {"name": "Transpose a key", "value": "transpose_key"},
{"name": "Transpose a value", "value": "transpose_value"}, {"name": "Transpose a value", "value": "transpose_value"},
questionary.Separator(),
{"name": "Back", "value": "back"}, {"name": "Back", "value": "back"},
] ]
match self.questions.ask_selection(choices=choices, question="Select an action to perform"): match self.questions.ask_selection(choices=choices, question="Select an action to perform"):

View File

@@ -3,16 +3,6 @@
from enum import Enum from enum import Enum
class MetadataType(Enum):
"""Enum class for the type of metadata."""
FRONTMATTER = "Frontmatter"
INLINE = "Inline Metadata"
TAGS = "Inline Tags"
KEYS = "Metadata Keys Only"
ALL = "All Metadata"
class InsertLocation(Enum): class InsertLocation(Enum):
"""Location to add metadata to notes. """Location to add metadata to notes.
@@ -25,3 +15,22 @@ class InsertLocation(Enum):
TOP = "Top" TOP = "Top"
AFTER_TITLE = "After title" AFTER_TITLE = "After title"
BOTTOM = "Bottom" BOTTOM = "Bottom"
class MetadataType(Enum):
"""Enum class for the type of metadata."""
ALL = "Inline, Frontmatter, and Tags"
FRONTMATTER = "Frontmatter"
INLINE = "Inline Metadata"
KEYS = "Metadata Keys Only"
META = "Inline and Frontmatter. No Tags"
TAGS = "Inline Tags"
class Wrapping(Enum):
"""Wrapping for inline metadata within a block of text."""
BRACKETS = "Brackets"
PARENS = "Parentheses"
NONE = None

View File

@@ -0,0 +1,17 @@
"""Custom exceptions for the obsidian_metadata package."""
class ObsidianMetadataError(Exception):
"""Base exception for the obsidian_metadata package."""
class FrontmatterError(ObsidianMetadataError):
"""Exception for errors in the frontmatter."""
class InlineMetadataError(ObsidianMetadataError):
"""Exception for errors in the inlined metadata."""
class InlineTagError(ObsidianMetadataError):
"""Exception for errors in the inline tags."""

View File

@@ -1,656 +1,138 @@
"""Work with metadata items.""" """Work with metadata items."""
import copy
import re import re
from io import StringIO from io import StringIO
from rich.columns import Columns import rich.repr
from rich.table import Table
from ruamel.yaml import YAML from ruamel.yaml import YAML
from obsidian_metadata._utils import ( from obsidian_metadata.models.enums import MetadataType, Wrapping
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_tag"
class VaultMetadata: def dict_to_yaml(dictionary: dict[str, list[str]], sort_keys: bool = False) -> str:
"""Representation of all Metadata in the Vault.""" """Return the a dictionary of {key: [values]} as a YAML string.
def __init__(self) -> None: Args:
self.dict: dict[str, list[str]] = {} dictionary (dict[str, list[str]]): Dictionary of {key: [values]}.
self.frontmatter: dict[str, list[str]] = {} sort_keys (bool, optional): Sort the keys. Defaults to False.
self.inline_metadata: dict[str, list[str]] = {}
self.tags: list[str] = []
def __repr__(self) -> str: Returns:
"""Representation of all metadata.""" str: Frontmatter as a YAML string.
return str(self.dict) sort_keys (bool, optional): Sort the keys. Defaults to False.
"""
if sort_keys:
dictionary = dict(sorted(dictionary.items()))
def index_metadata( for key, value in dictionary.items():
self, area: MetadataType, metadata: dict[str, list[str]] | list[str] if len(value) == 1:
dictionary[key] = value[0] # type: ignore [assignment]
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
string_stream = StringIO()
yaml.dump(dictionary, string_stream)
yaml_value = string_stream.getvalue()
string_stream.close()
if yaml_value == "{}\n":
return ""
return yaml_value
@rich.repr.auto
class InlineField:
"""Representation of a single inline field.
Attributes:
meta_type (MetadataType): Metadata category.
clean_key (str): Cleaned key - Key without surround markdown
key (str): Metadata key - Complete key found in note
key_close (str): Closing key markdown.
key_open (str): Opening key markdown.
normalized_key (str): Key converted to lowercase w. spaces replaced with dashes
normalized_value (str): Value stripped of leading and trailing whitespace.
value (str): Metadata value - Complete value found in note.
wrapping (Wrapping): Inline metadata may be wrapped with [] or ().
"""
def __init__(
self,
meta_type: MetadataType,
key: str,
value: str,
wrapping: Wrapping = Wrapping.NONE,
is_changed: bool = False,
) -> None: ) -> 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. self.meta_type = meta_type
self.key = key
Args: self.value = value
area (MetadataType): Type of metadata. self.wrapping = wrapping
metadata (dict): Metadata to add. self.is_changed = is_changed
"""
if isinstance(metadata, dict): # Clean keys of surrounding markdown and convert to lowercase
new_metadata = clean_dictionary(metadata) self.clean_key, self.normalized_key, self.key_open, self.key_close = (
self.dict = merge_dictionaries(self.dict, new_metadata) self._clean_key(self.key) if self.key else (None, None, "", "")
if area == MetadataType.FRONTMATTER:
self.frontmatter = merge_dictionaries(self.frontmatter, new_metadata)
if area == MetadataType.INLINE:
self.inline_metadata = merge_dictionaries(self.inline_metadata, new_metadata)
if area == MetadataType.TAGS and isinstance(metadata, list):
self.tags.extend(metadata)
self.tags = sorted({s.strip("#") for s in self.tags})
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:
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.
"""
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.
Args:
key (str): Key to check.
value_to_delete (str, optional): Value to delete.
Returns:
bool: True if a value was deleted
"""
new_dict = copy.deepcopy(self.dict)
if value_to_delete is None:
for _k in list(new_dict):
if re.search(key, _k):
del new_dict[_k]
else:
for _k, _v in new_dict.items():
if re.search(key, _k):
new_values = [x for x in _v if not re.search(value_to_delete, x)]
new_dict[_k] = sorted(new_values)
if new_dict != self.dict:
self.dict = dict(new_dict)
return True
return False
def print_metadata(self, area: MetadataType) -> None:
"""Print metadata to the terminal.
Args:
area (MetadataType): Type of metadata to print
"""
dict_to_print: dict[str, list[str]] = None
list_to_print: list[str] = None
match area:
case MetadataType.INLINE:
dict_to_print = self.inline_metadata.copy()
header = "All inline metadata"
case MetadataType.FRONTMATTER:
dict_to_print = self.frontmatter.copy()
header = "All frontmatter"
case MetadataType.TAGS:
list_to_print = []
for tag in self.tags:
list_to_print.append(f"#{tag}")
header = "All inline tags"
case MetadataType.KEYS:
list_to_print = sorted(self.dict.keys())
header = "All Keys"
case MetadataType.ALL:
dict_to_print = self.dict.copy()
list_to_print = []
for tag in self.tags:
list_to_print.append(f"#{tag}")
header = "All metadata"
if dict_to_print is not None:
table = Table(title=header, show_footer=False, show_lines=True)
table.add_column("Keys")
table.add_column("Values")
for key, value in sorted(dict_to_print.items()):
values: str | dict[str, list[str]] = (
"\n".join(sorted(value)) if isinstance(value, list) else value
)
table.add_row(f"[bold]{key}[/]", str(values))
console.print(table)
if list_to_print is not None:
columns = Columns(
sorted(list_to_print),
equal=True,
expand=True,
title=header if area != MetadataType.ALL else "All inline tags",
)
console.print(columns)
def rename(self, key: str, value_1: str, value_2: str = None) -> bool:
"""Replace a value in the frontmatter.
Args:
key (str): Key to check.
value_1 (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key
value_2 (str, Optional): New value.
bypass_check (bool, optional): Bypass the check if the key exists. Defaults to False.
Returns:
bool: True if a value was renamed
"""
if value_2 is None:
if key in self.dict and value_1 not in self.dict:
self.dict[value_1] = self.dict.pop(key)
return True
return False
if key in self.dict and value_1 in self.dict[key]:
self.dict[key] = sorted({value_2 if x == value_1 else x for x in self.dict[key]})
return True
return False
class 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]] = copy.deepcopy(self.dict)
def __repr__(self) -> str: # pragma: no cover
"""Representation of the frontmatter.
Returns:
str: frontmatter
"""
return f"Frontmatter(frontmatter={self.dict})"
def _grab_note_frontmatter(self, file_content: str) -> dict:
"""Grab metadata from a note.
Args:
file_content (str): Content of the note.
Returns:
dict: Metadata from the note.
"""
try:
frontmatter_block: str = PATTERNS.frontmatt_block_strip_separators.search(
file_content
).group("frontmatter")
except AttributeError:
return {}
yaml = YAML(typ="safe")
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:
frontmatter[k] = []
return dict_values_to_lists_strings(frontmatter, strip_null_values=True)
def add(self, key: str, value: str | list[str] = None) -> bool: # noqa: PLR0911
"""Add a key and value to the frontmatter.
Args:
key (str): Key to add.
value (str, optional): Value to add.
Returns:
bool: True if the metadata was added
"""
if value is None:
if key not in self.dict:
self.dict[key] = []
return True
return False
if key not in self.dict:
if isinstance(value, list):
self.dict[key] = value
return True
self.dict[key] = [value]
return True
if key in self.dict and value not in self.dict[key]:
if isinstance(value, list):
self.dict[key].extend(value)
return True
self.dict[key].append(value)
return True
return False
def contains(self, key: str, value: str = None, is_regex: bool = False) -> bool:
"""Check if a key or value exists in the metadata.
Args:
key (str): 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.
"""
return dict_contains(self.dict, key, value, is_regex)
def delete(self, key: str, value_to_delete: str = None) -> bool:
"""Delete a value or key in the frontmatter. Regex is supported to allow deleting more than one key or value.
Args:
key (str): If no value, key to delete. If value, key containing the value.
value_to_delete (str, optional): Value to delete.
Returns:
bool: True if a value was deleted
"""
new_dict = dict(self.dict)
if value_to_delete is None:
for _k in list(new_dict):
if re.search(key, _k):
del new_dict[_k]
else:
for _k, _v in new_dict.items():
if re.search(key, _k):
new_values = [x for x in _v if not re.search(value_to_delete, x)]
new_dict[_k] = sorted(new_values)
if new_dict != self.dict:
self.dict = dict(new_dict)
return True
return False
def has_changes(self) -> bool:
"""Check if the frontmatter has changes.
Returns:
bool: True if the frontmatter has changes.
"""
return self.dict != self.dict_original
def rename(self, key: str, value_1: str, value_2: str = None) -> bool:
"""Replace a value in the frontmatter.
Args:
key (str): Key to check.
value_1 (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key
value_2 (str, Optional): New value.
Returns:
bool: True if a value was renamed
"""
if value_2 is None:
if key in self.dict and value_1 not in self.dict:
self.dict[value_1] = self.dict.pop(key)
return True
return False
if key in self.dict and value_1 in self.dict[key]:
self.dict[key] = sorted({value_2 if x == value_1 else x for x in self.dict[key]})
return True
return False
def to_yaml(self, sort_keys: bool = False) -> str:
"""Return the frontmatter as a YAML string.
Returns:
str: Frontmatter as a YAML string.
sort_keys (bool, optional): Sort the keys. Defaults to False.
"""
dict_to_dump = copy.deepcopy(self.dict)
for k in dict_to_dump:
if dict_to_dump[k] == []:
dict_to_dump[k] = None
if isinstance(dict_to_dump[k], list) and len(dict_to_dump[k]) == 1:
new_val = dict_to_dump[k][0]
dict_to_dump[k] = new_val # type: ignore [assignment]
# Converting stream to string from https://stackoverflow.com/questions/47614862/best-way-to-use-ruamel-yaml-to-dump-yaml-to-string-not-to-stream/63179923#63179923
if sort_keys:
dict_to_dump = dict(sorted(dict_to_dump.items()))
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
string_stream = StringIO()
yaml.dump(dict_to_dump, string_stream)
yaml_value = string_stream.getvalue()
string_stream.close()
return yaml_value
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]] = copy.deepcopy(self.dict)
def __repr__(self) -> str: # pragma: no cover
"""Representation of inline metadata.
Returns:
str: inline metadata
"""
return f"InlineMetadata(inline_metadata={self.dict})"
def add(self, key: str, value: str | list[str] = None) -> bool: # noqa: PLR0911
"""Add a key and value to the inline metadata.
Args:
key (str): Key to add.
value (str, optional): Value to add.
Returns:
bool: True if the metadata was added
"""
if value is None:
if key not in self.dict:
self.dict[key] = []
return True
return False
if key not in self.dict:
if isinstance(value, list):
self.dict[key] = value
return True
self.dict[key] = [value]
return True
if key in self.dict and value not in self.dict[key]:
if isinstance(value, list):
self.dict[key].extend(value)
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.
Args:
key (str): Key to check.
value (str, Optional): Value to check.
is_regex (bool, optional): If True, key and value are treated as regex. Defaults to False.
Returns:
bool: True if the key exists.
"""
return dict_contains(self.dict, key, value, is_regex)
def delete(self, key: str, value_to_delete: str = None) -> bool:
"""Delete a value or key in the inline metadata. Regex is supported to allow deleting more than one key or value.
Args:
key (str): If no value, key to delete. If value, key containing the value.
value_to_delete (str, optional): Value to delete.
Returns:
bool: True if a value was deleted
"""
new_dict = dict(self.dict)
if value_to_delete is None:
for _k in list(new_dict):
if re.search(key, _k):
del new_dict[_k]
else:
for _k, _v in new_dict.items():
if re.search(key, _k):
new_values = [x for x in _v if not re.search(value_to_delete, x)]
new_dict[_k] = sorted(new_values)
if new_dict != self.dict:
self.dict = dict(new_dict)
return True
return False
def 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.
Returns:
bool: True if the metadata has changes.
"""
return self.dict != self.dict_original
def rename(self, key: str, value_1: str, value_2: str = None) -> bool:
"""Replace a value in the inline metadata.
Args:
key (str): Key to check.
value_1 (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key
value_2 (str, Optional): New value.
Returns:
bool: True if a value was renamed
"""
if value_2 is None:
if key in self.dict and value_1 not in self.dict:
self.dict[value_1] = self.dict.pop(key)
return True
return False
if key in self.dict and value_1 in self.dict[key]:
self.dict[key] = sorted({value_2 if x == value_1 else x for x in self.dict[key]})
return True
return False
class InlineTags:
"""Representation of inline tags."""
def __init__(self, file_content: str):
self.metadata_key = INLINE_TAG_KEY
self.list: list[str] = self._grab_inline_tags(file_content)
self.list_original: list[str] = self.list.copy()
def __repr__(self) -> str: # pragma: no cover
"""Representation of the inline tags.
Returns:
str: inline tags
"""
return f"InlineTags(tags={self.list})"
def _grab_inline_tags(self, file_content: str) -> list[str]:
"""Grab inline tags from a note.
Args:
file_content (str): Total contents of the note file (frontmatter and content).
Returns:
list[str]: Inline tags from the note.
"""
return sorted(
PATTERNS.find_inline_tags.findall(
remove_markdown_sections(
file_content,
strip_codeblocks=True,
strip_inlinecode=True,
)
)
) )
def add(self, new_tag: str | list[str]) -> bool: # Normalize value for display
"""Add a new inline tag. self.normalized_value = "-" if re.match(r"^\s*$", self.value) else self.value.strip()
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
"""Rich representation of the inline field."""
yield "clean_key", self.clean_key
yield "is_changed", self.is_changed
yield "key_close", self.key_close
yield "key_open", self.key_open
yield "key", self.key
yield "meta_type", self.meta_type.value
yield "normalized_key", self.normalized_key
yield "normalized_value", self.normalized_value
yield "value", self.value
yield "wrapping", self.wrapping.value
def __eq__(self, other: object) -> bool:
"""Compare two InlineField objects."""
if not isinstance(other, InlineField):
return NotImplemented
return (
self.key == other.key
and self.value == other.value
and self.meta_type == other.meta_type
)
def __hash__(self) -> int:
"""Hash the InlineField object."""
return hash((self.key, self.value, self.meta_type))
def _clean_key(self, text: str) -> tuple[str, str, str, str]:
"""Remove markdown from the key.
Creates the following attributes:
clean_key : The key stripped of opening and closing markdown
normalized_key: The key converted to lowercase with spaces replaced with dashes
key_open : The opening markdown
key_close : The closing markdown.
Args: Args:
new_tag (str): Tag to add. text (str): Key to clean.
Returns: Returns:
bool: True if a tag was added. tuple[str, str, str, str]: Cleaned key, normalized key, opening markdown, closing markdown.
""" """
if isinstance(new_tag, list): cleaned = text
for _tag in new_tag: if tmp := re.search(r"^([\*#_ `~]+)", text):
if _tag.startswith("#"): key_open = tmp.group(0)
_tag = _tag[1:] cleaned = re.sub(rf"^{re.escape(key_open)}", "", text)
if _tag in self.list:
return False
new_list = self.list.copy()
new_list.append(_tag)
self.list = sorted(new_list)
return True
else: else:
if new_tag.startswith("#"): key_open = ""
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 if tmp := re.search(r"([\*#_ `~]+)$", text):
key_close = tmp.group(0)
cleaned = re.sub(rf"{re.escape(key_close)}$", "", cleaned)
else:
key_close = ""
def contains(self, tag: str, is_regex: bool = False) -> bool: normalized = cleaned.replace(" ", "-").lower()
"""Check if a tag exists in the metadata.
Args: return cleaned, normalized, key_open, key_close
tag (str): Tag to check.
is_regex (bool, optional): If True, tag is treated as regex. Defaults to False.
Returns:
bool: True if the tag exists.
"""
if is_regex is True:
return any(re.search(tag, _t) for _t in self.list)
if tag in self.list:
return True
return False
def delete(self, tag_to_delete: str) -> bool:
"""Delete a specified inline tag. Regex is supported to allow deleting more than one tag.
Args:
tag_to_delete (str, optional): Value to delete.
Returns:
bool: True if a value was deleted
"""
new_list = sorted([x for x in self.list if re.search(tag_to_delete, x) is None])
if new_list != self.list:
self.list = new_list
return True
return False
def has_changes(self) -> bool:
"""Check if the metadata has changes.
Returns:
bool: True if the metadata has changes.
"""
return self.list != self.list_original
def rename(self, old_tag: str, new_tag: str) -> bool:
"""Replace an inline tag with another string.
Args:
old_tag (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key
new_tag (str, Optional): New value.
Returns:
bool: True if a value was renamed
"""
if old_tag in self.list:
self.list = sorted([new_tag if i == old_tag else i for i in self.list])
return True
return False

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
"""Parsers for Obsidian metadata files."""
from dataclasses import dataclass
import emoji
import regex as re
from obsidian_metadata.models.enums import Wrapping
@dataclass
class Parser:
"""Regex parsers for Obsidian metadata files.
All methods return a list of matches
"""
# Reusable regex patterns
internal_link = r"\[\[[^\[\]]*?\]\]" # An Obsidian link of the form [[<link>]]
chars_not_in_tags = r"\u2000-\u206F\u2E00-\u2E7F'!\"#\$%&\(\)\*+,\.:;<=>?@\^`\{\|\}~\[\]\\\s"
# Compiled regex patterns
tag = re.compile(
r"""
(?:
(?:^|\s|\\{2}) # If tarts with newline, space, or "\\""
(?P<tag>\#[^\u2000-\u206F\u2E00-\u2E7F'!\"\#\$%&\(\)\*+,\.:;<=>?@\^`\{\|\}~\[\]\\\s]+) # capture tag
| # Else
(?:(?<=
\#[^\u2000-\u206F\u2E00-\u2E7F'!\"\#\$%&\(\)\*+,\.:;<=>?@\^`\{\|\}~\[\]\\\s]+
)) # if lookbehind is a tag
(?P<tag>\#[^\u2000-\u206F\u2E00-\u2E7F'!\"\#\$%&\(\)\*+,\.:;<=>?@\^`\{\|\}~\[\]\\\s]+) # capture tag
| # Else
(*FAIL)
)
""",
re.X,
)
frontmatter_complete = re.compile(r"^\s*(?P<frontmatter>---.*?---)", flags=re.DOTALL)
frontmatter_data = re.compile(
r"(?P<open>^\s*---)(?P<frontmatter>.*?)(?P<close>---)", flags=re.DOTALL
)
code_block = re.compile(r"```.*?```", flags=re.DOTALL)
inline_code = re.compile(r"(?<!`{2})`[^`]+?` ?")
inline_metadata = re.compile(
r"""
(?: # Conditional
(?= # If opening wrapper is a bracket or parenthesis
(
(?<!\[)\[(?!\[) # Single bracket
| # Or
(?<!\()\((?!\() # Single parenthesis
)
)
(?: # Conditional
(?= # If opening wrapper is a bracket
(?<!\[)\[(?!\[) # Single bracket
)
(?<!\[)(?P<open>\[)(?!\[) # Open bracket
(?P<key>[0-9\p{Letter}\w\s_/-;\*\~`]+?) # Find key
(?<!:)::(?!:) # Separator
(?P<value>.*?) # Value
(?<!\])(?P<close>\])(?!\]) # Close bracket
| # Else if opening wrapper is a parenthesis
(?<!\()(?P<open>\()(?!\() # Open parens
(?P<key>[0-9\p{Letter}\w\s_/-;\*\~`]+?) # Find key
(?<!:)::(?!:) # Separator
(?P<value>.*?) # Value
(?<!\))(?P<close>\))(?!\)) # Close parenthesis
)
| # Else grab entire line
(?P<key>[0-9\p{Letter}\w\s_/-;\*\~`]+?) # Find key
(?<!:)::(?!:) # Separator
(?P<value>.*) # Value
)
""",
re.X | re.I,
)
top_with_header = re.compile(
r"""^\s* # Start of note
(?P<top> # Capture the top of the note
.* # Anything above the first header
\#+[ ].*?[\r\n] # Full header, if it exists
) # End capture group
""",
flags=re.DOTALL | re.X,
)
validate_key_text = re.compile(r"[^-_\w\d\/\*\u263a-\U0001f999]")
validate_tag_text = re.compile(r"[ \|,;:\*\(\)\[\]\\\.\n#&]")
def return_inline_metadata(self, line: str) -> list[tuple[str, str, Wrapping]] | None:
"""Return a list of metadata matches for a single line.
Args:
line (str): The text to search.
Returns:
list[tuple[str, str, Wrapping]] | None: A list of tuples containing the key, value, and wrapping type.
"""
sep = r"(?<!:)::(?!:)"
if not re.search(sep, line):
return None
# Replace emoji with text
line = emoji.demojize(line, delimiters=(";", ";"))
matches = []
for match in self.inline_metadata.finditer(line):
match match.group("open"):
case "[":
wrapper = Wrapping.BRACKETS
case "(":
wrapper = Wrapping.PARENS
case _:
wrapper = Wrapping.NONE
matches.append(
(
emoji.emojize(match.group("key"), delimiters=(";", ";")),
emoji.emojize(match.group("value"), delimiters=(";", ";")),
wrapper,
)
)
return matches
def return_frontmatter(self, text: str, data_only: bool = False) -> str | None:
"""Return a list of metadata matches.
Args:
text (str): The text to search.
data_only (bool, optional): If True, only return the frontmatter data and strip the "---" lines from the returned string. Defaults to False
Returns:
str | None: The frontmatter block, or None if no frontmatter is found.
"""
if data_only:
result = self.frontmatter_data.search(text)
else:
result = self.frontmatter_complete.search(text)
if result:
return result.group("frontmatter").strip()
return None
def return_tags(self, text: str) -> list[str]:
"""Return a list of tags.
Args:
text (str): The text to search.
Returns:
list[str]: A list of tags.
"""
return [
t.group("tag")
for t in self.tag.finditer(text)
if not re.match(r"^#[0-9]+$", t.group("tag"))
]
def return_top_with_header(self, text: str) -> str:
"""Returns the top content of a string until the end of the first markdown header found.
Args:
text (str): The text to search.
Returns:
str: The top content of the string.
"""
result = self.top_with_header.search(text)
if result:
return result.group("top")
return None
def strip_frontmatter(self, text: str, data_only: bool = False) -> str:
"""Strip frontmatter from a string.
Args:
text (str): The text to search.
data_only (bool, optional): If True, only strip the frontmatter data and leave the '---' lines. Defaults to False
"""
if data_only:
return self.frontmatter_data.sub(r"\g<open>\n\g<close>", text)
return self.frontmatter_complete.sub("", text)
def strip_code_blocks(self, text: str) -> str:
"""Strip code blocks from a string."""
return self.code_block.sub("", text)
def strip_inline_code(self, text: str) -> str:
"""Strip inline code from a string."""
return self.inline_code.sub("", text)

View File

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

View File

@@ -13,10 +13,10 @@ import questionary
import typer import typer
from obsidian_metadata.models.enums import InsertLocation, MetadataType from obsidian_metadata.models.enums import InsertLocation, MetadataType
from obsidian_metadata.models.patterns import Patterns from obsidian_metadata.models.parsers import Parser
from obsidian_metadata.models.vault import Vault from obsidian_metadata.models.vault import Vault
PATTERNS = Patterns() P = Parser()
# Reset the default style of the questionary prompts qmark # Reset the default style of the questionary prompts qmark
questionary.prompts.checkbox.DEFAULT_STYLE = questionary.Style([("qmark", "")]) questionary.prompts.checkbox.DEFAULT_STYLE = questionary.Style([("qmark", "")])
@@ -63,7 +63,7 @@ class Questions:
return True return True
def __init__(self, vault: Vault = None, key: str = None) -> None: def __init__(self, vault: Vault = None, key: str | None = None) -> None:
"""Initialize the class. """Initialize the class.
Args: Args:
@@ -86,7 +86,7 @@ class Questions:
self.vault = vault self.vault = vault
self.key = key self.key = key
def _validate_existing_inline_tag(self, text: str) -> bool | str: def _validate_existing_tag(self, text: str) -> bool | str:
"""Validate an existing inline tag. """Validate an existing inline tag.
Returns: Returns:
@@ -95,7 +95,7 @@ class Questions:
if len(text) < 1: if len(text) < 1:
return "Tag cannot be empty" return "Tag cannot be empty"
if not self.vault.metadata.contains(area=MetadataType.TAGS, value=text): if not self.vault.contains_metadata(meta_type=MetadataType.TAGS, key=None, value=text):
return f"'{text}' does not exist as a tag in the vault" return f"'{text}' does not exist as a tag in the vault"
return True return True
@@ -109,7 +109,7 @@ class Questions:
if len(text) < 1: if len(text) < 1:
return "Key cannot be empty" return "Key cannot be empty"
if not self.vault.metadata.contains(area=MetadataType.KEYS, key=text): if not self.vault.contains_metadata(meta_type=MetadataType.META, key=text):
return f"'{text}' does not exist as a key in the vault" return f"'{text}' does not exist as a key in the vault"
return True return True
@@ -128,7 +128,7 @@ class Questions:
except re.error as error: except re.error as error:
return f"Invalid regex: {error}" return f"Invalid regex: {error}"
if not self.vault.metadata.contains(area=MetadataType.KEYS, key=text, is_regex=True): if not self.vault.contains_metadata(meta_type=MetadataType.META, key=text, is_regex=True):
return f"'{text}' does not exist as a key in the vault" return f"'{text}' does not exist as a key in the vault"
return True return True
@@ -142,7 +142,7 @@ class Questions:
Returns: Returns:
bool | str: True if the key is valid, otherwise a string with the error message. bool | str: True if the key is valid, otherwise a string with the error message.
""" """
if PATTERNS.validate_key_text.search(text) is not None: if P.validate_key_text.search(text) is not None:
return "Key cannot contain spaces or special characters" return "Key cannot contain spaces or special characters"
if len(text) == 0: if len(text) == 0:
@@ -159,7 +159,7 @@ class Questions:
Returns: Returns:
bool | str: True if the tag is valid, otherwise a string with the error message. bool | str: True if the tag is valid, otherwise a string with the error message.
""" """
if PATTERNS.validate_tag_text.search(text) is not None: if P.validate_tag_text.search(text) is not None:
return "Tag cannot contain spaces or special characters" return "Tag cannot contain spaces or special characters"
if len(text) == 0: if len(text) == 0:
@@ -179,8 +179,8 @@ class Questions:
if len(text) < 1: if len(text) < 1:
return "Value cannot be empty" return "Value cannot be empty"
if self.key is not None and self.vault.metadata.contains( if self.key is not None and self.vault.contains_metadata(
area=MetadataType.ALL, key=self.key, value=text meta_type=MetadataType.ALL, key=self.key, value=text
): ):
return f"{self.key}:{text} already exists" return f"{self.key}:{text} already exists"
@@ -200,6 +200,23 @@ class Questions:
return True return True
def _validate_path_is_file(self, text: str) -> bool | str:
"""Validate a path is a file.
Args:
text (str): The path to validate.
Returns:
bool | str: True if the path is valid, otherwise a string with the error message.
"""
path_to_validate: Path = Path(text).expanduser().resolve()
if not path_to_validate.exists():
return f"Path does not exist: {path_to_validate}"
if not path_to_validate.is_file():
return f"Path is not a file: {path_to_validate}"
return True
def _validate_valid_vault_regex(self, text: str) -> bool | str: def _validate_valid_vault_regex(self, text: str) -> bool | str:
"""Validate a valid regex. """Validate a valid regex.
@@ -231,8 +248,8 @@ class Questions:
if len(text) == 0: if len(text) == 0:
return True return True
if self.key is not None and not self.vault.metadata.contains( if self.key is not None and not self.vault.contains_metadata(
area=MetadataType.ALL, key=self.key, value=text meta_type=MetadataType.ALL, key=self.key, value=text
): ):
return f"{self.key}:{text} does not exist" return f"{self.key}:{text} does not exist"
@@ -255,8 +272,8 @@ class Questions:
except re.error as error: except re.error as error:
return f"Invalid regex: {error}" return f"Invalid regex: {error}"
if self.key is not None and not self.vault.metadata.contains( if self.key is not None and not self.vault.contains_metadata(
area=MetadataType.ALL, key=self.key, value=text, is_regex=True meta_type=MetadataType.ALL, key=self.key, value=text, is_regex=True
): ):
return f"No values in {self.key} match regex: {text}" return f"No values in {self.key} match regex: {text}"
@@ -276,13 +293,15 @@ class Questions:
choices=[ choices=[
questionary.Separator("-------------------------------"), questionary.Separator("-------------------------------"),
{"name": "Vault Actions", "value": "vault_actions"}, {"name": "Vault Actions", "value": "vault_actions"},
{"name": "Export Metadata", "value": "export_metadata"},
{"name": "Inspect Metadata", "value": "inspect_metadata"}, {"name": "Inspect Metadata", "value": "inspect_metadata"},
{"name": "Filter Notes in Scope", "value": "filter_notes"}, {"name": "Filter Notes in Scope", "value": "filter_notes"},
questionary.Separator("-------------------------------"), questionary.Separator("-------------------------------"),
{"name": "Import bulk changes from CSV", "value": "import_from_csv"},
{"name": "Add Metadata", "value": "add_metadata"}, {"name": "Add Metadata", "value": "add_metadata"},
{"name": "Delete Metadata", "value": "delete_metadata"}, {"name": "Delete Metadata", "value": "delete_metadata"},
{"name": "Rename Metadata", "value": "rename_metadata"}, {"name": "Rename Metadata", "value": "rename_metadata"},
{"name": "Transpose Metadata", "value": "transpose_metadata"}, {"name": "Reorganize Metadata", "value": "reorganize_metadata"},
questionary.Separator("-------------------------------"), questionary.Separator("-------------------------------"),
{"name": "Review Changes", "value": "review_changes"}, {"name": "Review Changes", "value": "review_changes"},
{"name": "Commit Changes", "value": "commit_changes"}, {"name": "Commit Changes", "value": "commit_changes"},
@@ -294,23 +313,6 @@ class Questions:
qmark="INPUT |", qmark="INPUT |",
).ask() ).ask()
def ask_area(self) -> MetadataType | str: # pragma: no cover
"""Ask the user for the metadata area to work on.
Returns:
MetadataType: The metadata area to work on.
"""
choices = []
for metadata_type in MetadataType:
choices.append({"name": metadata_type.value, "value": metadata_type})
choices.append(questionary.Separator()) # type: ignore [arg-type]
choices.append({"name": "Cancel", "value": "cancel"})
return self.ask_selection(
choices=choices,
question="Select the type of metadata",
)
def ask_confirm(self, question: str, default: bool = True) -> bool: # pragma: no cover def ask_confirm(self, question: str, default: bool = True) -> bool: # pragma: no cover
"""Ask the user to confirm an action. """Ask the user to confirm an action.
@@ -325,11 +327,11 @@ class Questions:
question, default=default, style=self.style, qmark="INPUT |" question, default=default, style=self.style, qmark="INPUT |"
).ask() ).ask()
def ask_existing_inline_tag(self, question: str = "Enter a tag") -> str: # pragma: no cover def ask_existing_tag(self, question: str = "Enter a tag") -> str: # pragma: no cover
"""Ask the user for an existing inline tag.""" """Ask the user for an existing inline tag."""
return questionary.text( return questionary.text(
question, question,
validate=self._validate_existing_inline_tag, validate=self._validate_existing_tag,
style=self.style, style=self.style,
qmark="INPUT |", qmark="INPUT |",
).ask() ).ask()
@@ -423,7 +425,28 @@ class Questions:
return self.ask_selection( return self.ask_selection(
choices=choices, choices=choices,
question="Select the location for the metadata", question=question,
)
def ask_meta_type(self) -> MetadataType | str: # pragma: no cover
"""Ask the user for the type of metadata to work on.
Returns:
MetadataType: The metadata type
"""
choices = []
for meta_type in MetadataType:
match meta_type:
case MetadataType.ALL | MetadataType.META | MetadataType.KEYS:
continue
case _:
choices.append({"name": meta_type.value, "value": meta_type})
choices.append(questionary.Separator()) # type: ignore [arg-type]
choices.append({"name": "Cancel", "value": "cancel"})
return self.ask_selection(
choices=choices,
question="Select the type of metadata",
) )
def ask_new_key(self, question: str = "New key name") -> str: # pragma: no cover def ask_new_key(self, question: str = "New key name") -> str: # pragma: no cover
@@ -475,15 +498,27 @@ class Questions:
question, validate=self._validate_number, style=self.style, qmark="INPUT |" question, validate=self._validate_number, style=self.style, qmark="INPUT |"
).ask() ).ask()
def ask_path(self, question: str = "Enter a path") -> str: # pragma: no cover def ask_path(
self, question: str = "Enter a path", valid_file: bool = False
) -> str: # pragma: no cover
"""Ask the user for a path. """Ask the user for a path.
Args: Args:
question (str, optional): The question to ask. Defaults to "Enter a path". question (str, optional): The question to ask. Defaults to "Enter a path".
valid_file (bool, optional): Whether the path should be a valid file. Defaults to False.
Returns: Returns:
str: A path. str: A path.
""" """
if valid_file:
return questionary.path(
question,
only_directories=False,
style=self.style,
validate=self._validate_path_is_file,
qmark="INPUT |",
).ask()
return questionary.path(question, style=self.style, qmark="INPUT |").ask() return questionary.path(question, style=self.style, qmark="INPUT |").ask()
def ask_selection( def ask_selection(
@@ -498,7 +533,6 @@ class Questions:
Returns: Returns:
any: The selected item value. any: The selected item value.
""" """
choices.insert(0, questionary.Separator())
return questionary.select( return questionary.select(
question, question,
choices=choices, choices=choices,

View File

@@ -6,18 +6,20 @@ import re
import shutil import shutil
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any
import rich.repr import rich.repr
import typer
from rich import box from rich import box
from rich.progress import Progress, SpinnerColumn, TextColumn from rich.columns import Columns
from rich.prompt import Confirm from rich.prompt import Confirm
from rich.table import Table from rich.table import Table
from obsidian_metadata._config.config import VaultConfig from obsidian_metadata._config.config import VaultConfig
from obsidian_metadata._utils import alerts from obsidian_metadata._utils import alerts, dict_contains, merge_dictionaries
from obsidian_metadata._utils.alerts import logger as log from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata._utils.console import console from obsidian_metadata._utils.console import console, console_no_markup
from obsidian_metadata.models import InsertLocation, MetadataType, Note, VaultMetadata from obsidian_metadata.models import InsertLocation, MetadataType, Note
@dataclass @dataclass
@@ -46,27 +48,28 @@ class Vault:
config: VaultConfig, config: VaultConfig,
dry_run: bool = False, dry_run: bool = False,
filters: list[VaultFilter] = [], filters: list[VaultFilter] = [],
): ) -> None:
self.config = config.config self.config = config.config
self.vault_path: Path = config.path self.vault_path: Path = config.path
self.name = self.vault_path.name self.name = self.vault_path.name
self.insert_location: InsertLocation = self._find_insert_location() self.insert_location: InsertLocation = self._find_insert_location()
self.dry_run: bool = dry_run self.dry_run: bool = dry_run
self.backup_path: Path = self.vault_path.parent / f"{self.vault_path.name}.bak" self.backup_path: Path = self.vault_path.parent / f"{self.vault_path.name}.bak"
self.frontmatter: dict[str, list[str]] = {}
self.inline_meta: dict[str, list[str]] = {}
self.tags: list[str] = []
self.exclude_paths: list[Path] = [] self.exclude_paths: list[Path] = []
self.metadata = VaultMetadata()
for p in config.exclude_paths: for p in config.exclude_paths:
self.exclude_paths.append(Path(self.vault_path / p)) self.exclude_paths.append(Path(self.vault_path / p))
self.filters = filters self.filters = filters
self.all_note_paths = self._find_markdown_notes() self.all_note_paths = self._find_markdown_notes()
with Progress( with console.status(
SpinnerColumn(), "Processing notes... [dim](Can take a while for a large vault)[/]",
TextColumn("[progress.description]{task.description}"), spinner="bouncingBall",
transient=True, ):
) as progress:
progress.add_task(description="Processing notes...", total=None)
self.all_notes: list[Note] = [ self.all_notes: list[Note] = [
Note(note_path=p, dry_run=self.dry_run) for p in self.all_note_paths Note(note_path=p, dry_run=self.dry_run) for p in self.all_note_paths
] ]
@@ -76,13 +79,16 @@ class Vault:
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
"""Define rich representation of Vault.""" """Define rich representation of Vault."""
yield "vault_path", self.vault_path
yield "dry_run", self.dry_run
yield "backup_path", self.backup_path yield "backup_path", self.backup_path
yield "num_notes", len(self.all_notes) yield "config", self.config
yield "num_notes_in_scope", len(self.notes_in_scope) yield "dry_run", self.dry_run
yield "exclude_paths", self.exclude_paths yield "exclude_paths", self.exclude_paths
yield "filters", self.filters
yield "insert_location", self.insert_location yield "insert_location", self.insert_location
yield "name", self.name
yield "num_notes_in_scope", len(self.notes_in_scope)
yield "num_notes", len(self.all_notes)
yield "vault_path", self.vault_path
def _filter_notes(self) -> list[Note]: def _filter_notes(self) -> list[Note]:
"""Filter notes by path and metadata using the filters defined in self.filters. """Filter notes by path and metadata using the filters defined in self.filters.
@@ -101,16 +107,33 @@ class Vault:
] ]
if _filter.tag_filter is not None: if _filter.tag_filter is not None:
notes_list = [n for n in notes_list if n.contains_inline_tag(_filter.tag_filter)] notes_list = [
n
for n in notes_list
if n.contains_metadata(
MetadataType.TAGS, search_key="", search_value=_filter.tag_filter
)
]
if _filter.key_filter is not None and _filter.value_filter is not None: if _filter.key_filter is not None and _filter.value_filter is not None:
notes_list = [ notes_list = [
n n
for n in notes_list for n in notes_list
if n.contains_metadata(_filter.key_filter, _filter.value_filter) if n.contains_metadata(
meta_type=MetadataType.META,
search_key=_filter.key_filter,
search_value=_filter.value_filter,
)
] ]
if _filter.key_filter is not None and _filter.value_filter is None: 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)] notes_list = [
n
for n in notes_list
if n.contains_metadata(
MetadataType.META, search_key=_filter.key_filter, search_value=None
)
]
return notes_list return notes_list
@@ -164,39 +187,60 @@ class Vault:
] ]
def _rebuild_vault_metadata(self) -> None: def _rebuild_vault_metadata(self) -> None:
"""Rebuild vault metadata.""" """Rebuild vault metadata. Indexes all frontmatter, inline metadata, and tags and adds them to dictionary objects."""
self.metadata = VaultMetadata() with console.status(
with Progress( "Processing notes... [dim](Can take a while for a large vault)[/]",
SpinnerColumn(), spinner="bouncingBall",
TextColumn("[progress.description]{task.description}"), ):
transient=True, vault_frontmatter = {}
) as progress: vault_inline_meta = {}
progress.add_task(description="Processing notes...", total=None) vault_tags = []
for _note in self.notes_in_scope: for _note in self.notes_in_scope:
self.metadata.index_metadata( for field in _note.metadata:
area=MetadataType.FRONTMATTER, metadata=_note.frontmatter.dict match field.meta_type:
) case MetadataType.FRONTMATTER:
self.metadata.index_metadata( if field.clean_key not in vault_frontmatter:
area=MetadataType.INLINE, metadata=_note.inline_metadata.dict vault_frontmatter[field.clean_key] = (
) [field.normalized_value]
self.metadata.index_metadata( if field.normalized_value != "-"
area=MetadataType.TAGS, else []
metadata=_note.inline_tags.list, )
) elif field.normalized_value != "-":
vault_frontmatter[field.clean_key].append(field.normalized_value)
case MetadataType.INLINE:
if field.clean_key not in vault_inline_meta:
vault_inline_meta[field.clean_key] = (
[field.normalized_value]
if field.normalized_value != "-"
else []
)
elif field.normalized_value != "-":
vault_inline_meta[field.clean_key].append(field.normalized_value)
case MetadataType.TAGS:
if field.normalized_value not in vault_tags:
vault_tags.append(field.normalized_value)
self.frontmatter = {
k: sorted(list(set(v))) for k, v in sorted(vault_frontmatter.items())
}
self.inline_meta = {
k: sorted(list(set(v))) for k, v in sorted(vault_inline_meta.items())
}
self.tags = sorted(list(set(vault_tags)))
def add_metadata( def add_metadata(
self, self,
area: MetadataType, meta_type: MetadataType,
key: str = None, key: str | None = None,
value: str | list[str] = None, value: str | None = None,
location: InsertLocation = None, location: InsertLocation = None,
) -> int: ) -> int:
"""Add metadata to all notes in the vault which do not already contain it. """Add metadata to all notes in the vault which do not already contain it.
Args: Args:
area (MetadataType): Area of metadata to add to. meta_type (MetadataType): Area of metadata to add to.
key (str): Key to add. key (str): Key to add.
value (str|list, optional): Value to add. value (str, optional): Value to add.
location (InsertLocation, optional): Location to insert metadata. (Defaults to `vault.config.insert_location`) location (InsertLocation, optional): Location to insert metadata. (Defaults to `vault.config.insert_location`)
Returns: Returns:
@@ -208,7 +252,10 @@ class Vault:
num_changed = 0 num_changed = 0
for _note in self.notes_in_scope: for _note in self.notes_in_scope:
if _note.add_metadata(area=area, key=key, value=value, location=location): if _note.add_metadata(
meta_type=meta_type, added_key=key, added_value=value, location=location
):
log.trace(f"Added metadata to {_note.note_path}")
num_changed += 1 num_changed += 1
if num_changed > 0: if num_changed > 0:
@@ -255,6 +302,43 @@ class Vault:
log.trace(f"writing to {_note.note_path}") log.trace(f"writing to {_note.note_path}")
_note.commit() _note.commit()
def contains_metadata(
self, meta_type: MetadataType, key: str, value: str | None = None, is_regex: bool = False
) -> bool:
"""Check if the vault contains metadata.
Args:
meta_type (MetadataType): Area of metadata to check.
key (str): Key to check.
value (str, optional): Value to check. Defaults to None.
is_regex (bool, optional): Whether the value is a regex. Defaults to False.
Returns:
bool: Whether the vault contains the metadata.
"""
if meta_type == MetadataType.FRONTMATTER and key is not None:
return dict_contains(self.frontmatter, key, value, is_regex)
if meta_type == MetadataType.INLINE and key is not None:
return dict_contains(self.inline_meta, key, value, is_regex)
if meta_type == MetadataType.TAGS and value is not None:
if not is_regex:
value = f"^{re.escape(value)}$"
return any(re.search(value, item) for item in self.tags)
if meta_type == MetadataType.META:
return self.contains_metadata(
MetadataType.FRONTMATTER, key, value, is_regex
) or self.contains_metadata(MetadataType.INLINE, key, value, is_regex)
if meta_type == MetadataType.ALL:
return self.contains_metadata(
MetadataType.TAGS, key, value, is_regex
) or self.contains_metadata(MetadataType.META, key, value, is_regex)
return False
def delete_backup(self) -> None: def delete_backup(self) -> None:
"""Delete the vault backup.""" """Delete the vault backup."""
log.debug("Deleting vault backup") log.debug("Deleting vault backup")
@@ -266,7 +350,7 @@ class Vault:
else: else:
alerts.info("No backup found") alerts.info("No backup found")
def delete_inline_tag(self, tag: str) -> int: def delete_tag(self, tag: str) -> int:
"""Delete an inline tag in the vault. """Delete an inline tag in the vault.
Args: Args:
@@ -278,7 +362,8 @@ class Vault:
num_changed = 0 num_changed = 0
for _note in self.notes_in_scope: for _note in self.notes_in_scope:
if _note.delete_inline_tag(tag): if _note.delete_metadata(MetadataType.TAGS, value=tag):
log.trace(f"Deleted tag from {_note.note_path}")
num_changed += 1 num_changed += 1
if num_changed > 0: if num_changed > 0:
@@ -286,10 +371,18 @@ class Vault:
return num_changed return num_changed
def delete_metadata(self, key: str, value: str = None) -> int: def delete_metadata(
self,
key: str,
value: str | None = None,
meta_type: MetadataType = MetadataType.ALL,
is_regex: bool = False,
) -> int:
"""Delete metadata in the vault. """Delete metadata in the vault.
Args: Args:
meta_type (MetadataType): Area of metadata to delete from.
is_regex (bool): Whether to use regex for key and value. Defaults to False.
key (str): Key to delete. Regex is supported key (str): Key to delete. Regex is supported
value (str, optional): Value to delete. Regex is supported value (str, optional): Value to delete. Regex is supported
@@ -299,7 +392,8 @@ class Vault:
num_changed = 0 num_changed = 0
for _note in self.notes_in_scope: for _note in self.notes_in_scope:
if _note.delete_metadata(key, value): if _note.delete_metadata(meta_type=meta_type, key=key, value=value, is_regex=is_regex):
log.trace(f"Deleted metadata from {_note.note_path}")
num_changed += 1 num_changed += 1
if num_changed > 0: if num_changed > 0:
@@ -315,42 +409,78 @@ class Vault:
export_format (str, optional): Export as 'csv' or 'json'. Defaults to "csv". export_format (str, optional): Export as 'csv' or 'json'. Defaults to "csv".
""" """
export_file = Path(path).expanduser().resolve() export_file = Path(path).expanduser().resolve()
if not export_file.parent.exists():
alerts.error(f"Path does not exist: {export_file.parent}")
raise typer.Exit(code=1)
match export_format: match export_format:
case "csv": case "csv":
with open(export_file, "w", encoding="UTF8") as f: with export_file.open(mode="w", encoding="utf-8") as f:
writer = csv.writer(f) writer = csv.writer(f)
writer.writerow(["Metadata Type", "Key", "Value"]) writer.writerow(["Metadata Type", "Key", "Value"])
for key, value in self.metadata.frontmatter.items(): for key, value in self.frontmatter.items():
if isinstance(value, list): if len(value) > 0:
if len(value) > 0: for v in value:
for v in value:
writer.writerow(["frontmatter", key, v])
else:
writer.writerow(["frontmatter", key, v]) writer.writerow(["frontmatter", key, v])
else:
writer.writerow(["frontmatter", key, ""])
for key, value in self.metadata.inline_metadata.items(): for key, value in self.inline_meta.items():
if isinstance(value, list): if len(value) > 0:
if len(value) > 0: for v in value:
for v in value: writer.writerow(["inline_metadata", key, v])
writer.writerow(["inline_metadata", key, v]) else:
else: writer.writerow(["inline_metadata", key, ""])
writer.writerow(["frontmatter", key, v])
for tag in self.metadata.tags: for tag in self.tags:
writer.writerow(["tags", "", f"{tag}"]) writer.writerow(["tags", "", f"{tag}"])
case "json": case "json":
dict_to_dump = { dict_to_dump = {
"frontmatter": self.metadata.dict, "frontmatter": self.frontmatter,
"inline_metadata": self.metadata.inline_metadata, "inline_metadata": self.inline_meta,
"tags": self.metadata.tags, "tags": self.tags,
} }
with open(export_file, "w", encoding="UTF8") as f: with export_file.open(mode="w", encoding="utf-8") as f:
json.dump(dict_to_dump, f, indent=4, ensure_ascii=False, sort_keys=True) json.dump(dict_to_dump, f, indent=4, ensure_ascii=False, sort_keys=True)
def export_notes_to_csv(self, path: str) -> None:
"""Export notes and their associated metadata to a csv file. This is useful as a template for importing metadata changes to a vault.
Args:
path (str): Path to write csv file to.
"""
export_file = Path(path).expanduser().resolve()
if not export_file.parent.exists():
alerts.error(f"Path does not exist: {export_file.parent}")
raise typer.Exit(code=1)
with export_file.open(mode="w", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["path", "type", "key", "value"])
for _note in self.all_notes:
for field in sorted(
_note.metadata,
key=lambda x: (
x.meta_type.name,
x.clean_key,
x.normalized_value,
),
):
writer.writerow(
[
_note.note_path.relative_to(self.vault_path),
field.meta_type.name,
field.clean_key if field.clean_key is not None else "",
field.normalized_value if field.normalized_value != "-" else "",
]
)
def get_changed_notes(self) -> list[Note]: def get_changed_notes(self) -> list[Note]:
"""Returns a list of notes that have changes. """Return a list of notes that have changes.
Returns: Returns:
list[Note]: List of notes that have changes. list[Note]: List of notes that have changes.
@@ -360,8 +490,7 @@ class Vault:
if _note.has_changes(): if _note.has_changes():
changed_notes.append(_note) changed_notes.append(_note)
changed_notes = sorted(changed_notes, key=lambda x: x.note_path) return sorted(changed_notes, key=lambda x: x.note_path)
return changed_notes
def info(self) -> None: def info(self) -> None:
"""Print information about the vault.""" """Print information about the vault."""
@@ -377,20 +506,91 @@ class Vault:
table.add_row("Notes with changes", str(len(self.get_changed_notes()))) table.add_row("Notes with changes", str(len(self.get_changed_notes())))
table.add_row("Insert Location", str(self.insert_location.value)) table.add_row("Insert Location", str(self.insert_location.value))
console.print(table) console_no_markup.print(table)
def list_editable_notes(self) -> None: def list_editable_notes(self) -> None:
"""Print a list of notes within the scope that are being edited.""" """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) table = Table(title="Notes in current scope", show_header=False, box=box.HORIZONTALS)
for _n, _note in enumerate(self.notes_in_scope, 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))) table.add_row(str(_n), str(_note.note_path.relative_to(self.vault_path)))
console.print(table) console_no_markup.print(table)
def move_inline_metadata(self, location: InsertLocation) -> int:
"""Move all inline metadata to the selected location.
Args:
location (InsertLocation): Location to move inline metadata to.
Returns:
int: Number of notes that had inline metadata moved.
"""
num_changed = 0
for _note in self.notes_in_scope:
if _note.transpose_metadata(
begin=MetadataType.INLINE,
end=MetadataType.INLINE,
key=None,
value=None,
location=location,
):
log.trace(f"Moved inline metadata in {_note.note_path}")
num_changed += 1
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed
def num_excluded_notes(self) -> int: def num_excluded_notes(self) -> int:
"""Count number of excluded notes.""" """Count number of excluded notes."""
return len(self.all_notes) - len(self.notes_in_scope) return len(self.all_notes) - len(self.notes_in_scope)
def rename_inline_tag(self, old_tag: str, new_tag: str) -> int: def print_metadata(self, meta_type: MetadataType = MetadataType.ALL) -> None:
"""Print metadata for the vault."""
dict_to_print = None
list_to_print = None
match meta_type:
case MetadataType.INLINE:
dict_to_print = self.inline_meta
header = "All inline metadata"
case MetadataType.FRONTMATTER:
dict_to_print = self.frontmatter
header = "All frontmatter"
case MetadataType.TAGS:
list_to_print = [f"#{x}" for x in self.tags]
header = "All inline tags"
case MetadataType.KEYS:
list_to_print = sorted(
merge_dictionaries(self.frontmatter, self.inline_meta).keys()
)
header = "All Keys"
case MetadataType.ALL:
dict_to_print = merge_dictionaries(self.frontmatter, self.inline_meta)
list_to_print = [f"#{x}" for x in self.tags]
header = "All metadata"
if dict_to_print is not None:
table = Table(title=header, show_footer=False, show_lines=True)
table.add_column("Keys", style="bold")
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"{key}", str(values))
console_no_markup.print(table)
if list_to_print is not None:
columns = Columns(
sorted(list_to_print),
equal=True,
expand=True,
title=header if meta_type != MetadataType.ALL else "All inline tags",
)
console_no_markup.print(columns)
def rename_tag(self, old_tag: str, new_tag: str) -> int:
"""Rename an inline tag in the vault. """Rename an inline tag in the vault.
Args: Args:
@@ -403,7 +603,8 @@ class Vault:
num_changed = 0 num_changed = 0
for _note in self.notes_in_scope: for _note in self.notes_in_scope:
if _note.rename_inline_tag(old_tag, new_tag): if _note.rename_tag(old_tag, new_tag):
log.trace(f"Renamed inline tag in {_note.note_path}")
num_changed += 1 num_changed += 1
if num_changed > 0: if num_changed > 0:
@@ -411,8 +612,8 @@ class Vault:
return num_changed return num_changed
def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> int: def rename_metadata(self, key: str, value_1: str, value_2: str | None = None) -> int:
"""Renames a key or key-value pair in the note's metadata. """Rename a key or key-value pair in the note's metadata.
If no value is provided, will rename an entire key. If no value is provided, will rename an entire key.
@@ -428,6 +629,7 @@ class Vault:
for _note in self.notes_in_scope: for _note in self.notes_in_scope:
if _note.rename_metadata(key, value_1, value_2): if _note.rename_metadata(key, value_1, value_2):
log.trace(f"Renamed metadata in {_note.note_path}")
num_changed += 1 num_changed += 1
if num_changed > 0: if num_changed > 0:
@@ -435,12 +637,12 @@ class Vault:
return num_changed return num_changed
def transpose_metadata( # noqa: PLR0913 def transpose_metadata(
self, self,
begin: MetadataType, begin: MetadataType,
end: MetadataType, end: MetadataType,
key: str = None, key: str | None = None,
value: str | list[str] = None, value: str | None = None,
location: InsertLocation = None, location: InsertLocation = None,
) -> int: ) -> int:
"""Transpose metadata from one type to another. """Transpose metadata from one type to another.
@@ -469,6 +671,65 @@ class Vault:
): ):
num_changed += 1 num_changed += 1
if num_changed > 0:
self._rebuild_vault_metadata()
log.trace(f"Transposed metadata in {_note.note_path}")
return num_changed
def update_from_dict(self, dictionary: dict[str, Any]) -> int:
"""Update note metadata from a dictionary. This method is used when updating note metadata from a CSV file. This is a destructive operation. All existing metadata in the specified notes not in the dictionary will be removed.
Requires a dictionary with the note path as the key and a dictionary of metadata as the value. Each key must have a list of associated dictionaries in the following format:
{
'type': 'frontmatter|inline_metadata|tag',
'key': 'string',
'value': 'string'
}
Args:
dictionary (dict[str, Any]): Dictionary to update metadata from.
Returns:
int: Number of notes that had metadata updated.
"""
num_changed = 0
for _note in self.all_notes:
path = _note.note_path.relative_to(self.vault_path)
if str(path) in dictionary:
log.debug(f"Bulk update metadata for '{path}'")
num_changed += 1
# Deleta all existing metadata in the note
_note.delete_metadata(meta_type=MetadataType.META, key=r".*", is_regex=True)
_note.delete_metadata(meta_type=MetadataType.TAGS, value=r".*", is_regex=True)
# Add the new metadata
for row in dictionary[str(path)]:
if row["type"].lower() == "frontmatter":
_note.add_metadata(
meta_type=MetadataType.FRONTMATTER,
added_key=row["key"],
added_value=row["value"],
)
if row["type"].lower() == "inline_metadata":
_note.add_metadata(
meta_type=MetadataType.INLINE,
added_key=row["key"],
added_value=row["value"],
location=self.insert_location,
)
if row["type"].lower() == "tag":
_note.add_metadata(
meta_type=MetadataType.TAGS,
added_value=row["value"],
location=self.insert_location,
)
if num_changed > 0: if num_changed > 0:
self._rebuild_vault_metadata() self._rebuild_vault_metadata()

View File

@@ -6,86 +6,87 @@ import pytest
from obsidian_metadata._utils import alerts from obsidian_metadata._utils import alerts
from obsidian_metadata._utils.alerts import logger as log from obsidian_metadata._utils.alerts import logger as log
from tests.helpers import Regex from tests.helpers import Regex, strip_ansi
def test_dryrun(capsys): def test_dryrun(capsys):
"""Test dry run.""" """Test dry run."""
alerts.dryrun("This prints in dry run") alerts.dryrun("This prints in dry run")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "DRYRUN | This prints in dry run\n" assert captured == "DRYRUN | This prints in dry run\n"
def test_success(capsys): def test_success(capsys):
"""Test success.""" """Test success."""
alerts.success("This prints in success") alerts.success("This prints in success")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "SUCCESS | This prints in success\n" assert captured == "SUCCESS | This prints in success\n"
def test_error(capsys): def test_error(capsys):
"""Test success.""" """Test success."""
alerts.error("This prints in error") alerts.error("This prints in error")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "ERROR | This prints in error\n" assert captured == "ERROR | This prints in error\n"
def test_warning(capsys): def test_warning(capsys):
"""Test warning.""" """Test warning."""
alerts.warning("This prints in warning") alerts.warning("This prints in warning")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "WARNING | This prints in warning\n" assert captured == "WARNING | This prints in warning\n"
def test_notice(capsys): def test_notice(capsys):
"""Test notice.""" """Test notice."""
alerts.notice("This prints in notice") alerts.notice("This prints in notice")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "NOTICE | This prints in notice\n" assert captured == "NOTICE | This prints in notice\n"
def test_alerts_debug(capsys): def test_alerts_debug(capsys):
"""Test debug.""" """Test debug."""
alerts.debug("This prints in debug") alerts.debug("This prints in debug")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "DEBUG | This prints in debug\n" assert captured == "DEBUG | This prints in debug\n"
def test_usage(capsys): def test_usage(capsys):
"""Test usage.""" """Test usage."""
alerts.usage("This prints in usage") alerts.usage("This prints in usage")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "USAGE | This prints in usage\n" assert captured == "USAGE | This prints in usage\n"
alerts.usage( alerts.usage(
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua" "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
width=80,
) )
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert "USAGE | Lorem ipsum dolor sit amet" in captured.out assert "USAGE | Lorem ipsum dolor sit amet" in captured
assert " | incididunt ut labore et dolore magna aliqua" in captured.out assert " | incididunt ut labore et dolore magna aliqua" in captured
alerts.usage( alerts.usage(
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
width=20, width=20,
) )
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert "USAGE | Lorem ipsum dolor" in captured.out assert "USAGE | Lorem ipsum dolor" in captured
assert " | sit amet," in captured.out assert " | sit amet," in captured
assert " | adipisicing elit," in captured.out assert " | adipisicing elit," in captured
def test_info(capsys): def test_info(capsys):
"""Test info.""" """Test info."""
alerts.info("This prints in info") alerts.info("This prints in info")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "INFO | This prints in info\n" assert captured == "INFO | This prints in info\n"
def test_dim(capsys): def test_dim(capsys):
"""Test info.""" """Test info."""
alerts.dim("This prints in dim") alerts.dim("This prints in dim")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "This prints in dim\n" assert captured == "This prints in dim\n"
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -105,74 +106,74 @@ def test_logging(capsys, tmp_path, verbosity, log_to_file) -> None:
if verbosity >= 3: if verbosity >= 3:
assert logging.is_trace() is True assert logging.is_trace() is True
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "" assert not captured
assert logging.is_trace("trace text") is True assert logging.is_trace("trace text") is True
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "trace text\n" assert captured == "trace text\n"
log.trace("This is Trace logging") log.trace("This is Trace logging")
captured = capsys.readouterr() cap_error = strip_ansi(capsys.readouterr().err)
assert captured.err == Regex(r"^TRACE \| This is Trace logging \([\w\._:]+:\d+\)$") assert cap_error == Regex(r"^TRACE \| This is Trace logging \([\w\._:]+:\d+\)$")
else: else:
assert logging.is_trace("trace text") is False assert logging.is_trace("trace text") is False
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out != "trace text\n" assert captured != "trace text\n"
log.trace("This is Trace logging") log.trace("This is Trace logging")
captured = capsys.readouterr() cap_error = strip_ansi(capsys.readouterr().err)
assert captured.err != Regex(r"^TRACE \| This is Trace logging \([\w\._:]+:\d+\)$") assert cap_error != Regex(r"^TRACE \| This is Trace logging \([\w\._:]+:\d+\)$")
if verbosity >= 2: if verbosity >= 2:
assert logging.is_debug() is True assert logging.is_debug() is True
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "" assert not captured
assert logging.is_debug("debug text") is True assert logging.is_debug("debug text") is True
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "debug text\n" assert captured == "debug text\n"
log.debug("This is Debug logging") log.debug("This is Debug logging")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().err)
assert captured.err == Regex(r"^DEBUG \| This is Debug logging \([\w\._:]+:\d+\)$") assert captured == Regex(r"^DEBUG \| This is Debug logging \([\w\._:]+:\d+\)$")
else: else:
assert logging.is_debug("debug text") is False assert logging.is_debug("debug text") is False
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out != "debug text\n" assert captured != "debug text\n"
log.debug("This is Debug logging") log.debug("This is Debug logging")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().err)
assert captured.err != Regex(r"^DEBUG \| This is Debug logging \([\w\._:]+:\d+\)$") assert captured != Regex(r"^DEBUG \| This is Debug logging \([\w\._:]+:\d+\)$")
if verbosity >= 1: if verbosity >= 1:
assert logging.is_info() is True assert logging.is_info() is True
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "" assert not captured
assert logging.is_info("info text") is True assert logging.is_info("info text") is True
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "info text\n" assert captured == "info text\n"
log.info("This is Info logging") log.info("This is Info logging")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().err)
assert captured.err == "INFO | This is Info logging\n" assert captured == "INFO | This is Info logging\n"
else: else:
assert logging.is_info("info text") is False assert logging.is_info("info text") is False
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out != "info text\n" assert captured != "info text\n"
log.info("This is Info logging") log.info("This is Info logging")
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "" assert not captured
assert logging.is_default() is True assert logging.is_default() is True
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "" assert not captured
assert logging.is_default("default text") is True assert logging.is_default("default text") is True
captured = capsys.readouterr() captured = strip_ansi(capsys.readouterr().out)
assert captured.out == "default text\n" assert captured == "default text\n"
if log_to_file: if log_to_file:
assert tmp_log.exists() is True assert tmp_log.exists() is True

View File

@@ -13,11 +13,16 @@ from pathlib import Path
import pytest import pytest
from obsidian_metadata.models.enums import MetadataType from obsidian_metadata.models.enums import MetadataType
from tests.helpers import Regex, remove_ansi from tests.helpers import Regex, strip_ansi
def test_instantiate_application(test_application) -> None: def test_instantiate_application(test_application) -> None:
"""Test application.""" """Test application.
GIVEN an application
WHEN the application is instantiated
THEN check the attributes are set correctly
"""
app = test_application app = test_application
app._load_vault() app._load_vault()
@@ -29,7 +34,12 @@ def test_instantiate_application(test_application) -> None:
def test_abort(test_application, mocker, capsys) -> None: def test_abort(test_application, mocker, capsys) -> None:
"""Test renaming a key.""" """Test aborting the application.
GIVEN an application
WHEN the users selects "abort" from the main menu
THEN check the application exits
"""
app = test_application app = test_application
app._load_vault() app._load_vault()
mocker.patch( mocker.patch(
@@ -38,12 +48,17 @@ def test_abort(test_application, mocker, capsys) -> None:
) )
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert "Done!" in captured assert "Done!" in captured
def test_add_metadata_frontmatter(test_application, mocker, capsys) -> None: def test_add_metadata_frontmatter(test_application, mocker, capsys) -> None:
"""Test adding new metadata to the vault.""" """Test adding new metadata to the vault.
GIVEN an application
WHEN the wants to update a key in the frontmatter
THEN check the application updates the key
"""
app = test_application app = test_application
app._load_vault() app._load_vault()
mocker.patch( mocker.patch(
@@ -51,7 +66,7 @@ def test_add_metadata_frontmatter(test_application, mocker, capsys) -> None:
side_effect=["add_metadata", KeyError], side_effect=["add_metadata", KeyError],
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_area", "obsidian_metadata.models.application.Questions.ask_meta_type",
return_value=MetadataType.FRONTMATTER, return_value=MetadataType.FRONTMATTER,
) )
mocker.patch( mocker.patch(
@@ -65,12 +80,17 @@ def test_add_metadata_frontmatter(test_application, mocker, capsys) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL) assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL)
def test_add_metadata_inline(test_application, mocker, capsys) -> None: def test_add_metadata_inline(test_application, mocker, capsys) -> None:
"""Test adding new metadata to the vault.""" """Test adding new metadata to the vault.
GIVEN an application
WHEN the user wants to add a key in the inline metadata
THEN check the application updates the key
"""
app = test_application app = test_application
app._load_vault() app._load_vault()
mocker.patch( mocker.patch(
@@ -78,7 +98,7 @@ def test_add_metadata_inline(test_application, mocker, capsys) -> None:
side_effect=["add_metadata", KeyError], side_effect=["add_metadata", KeyError],
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_area", "obsidian_metadata.models.application.Questions.ask_meta_type",
return_value=MetadataType.INLINE, return_value=MetadataType.INLINE,
) )
mocker.patch( mocker.patch(
@@ -92,12 +112,17 @@ def test_add_metadata_inline(test_application, mocker, capsys) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL) assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL)
def test_add_metadata_tag(test_application, mocker, capsys) -> None: def test_add_metadata_tag(test_application, mocker, capsys) -> None:
"""Test adding new metadata to the vault.""" """Test adding new metadata to the vault.
GIVEN an application
WHEN the user wants to add a tag
THEN check the application adds the tag
"""
app = test_application app = test_application
app._load_vault() app._load_vault()
mocker.patch( mocker.patch(
@@ -105,7 +130,7 @@ def test_add_metadata_tag(test_application, mocker, capsys) -> None:
side_effect=["add_metadata", KeyError], side_effect=["add_metadata", KeyError],
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_area", "obsidian_metadata.models.application.Questions.ask_meta_type",
return_value=MetadataType.TAGS, return_value=MetadataType.TAGS,
) )
mocker.patch( mocker.patch(
@@ -115,12 +140,17 @@ def test_add_metadata_tag(test_application, mocker, capsys) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL) assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL)
def test_delete_inline_tag(test_application, mocker, capsys) -> None: def test_delete_tag_1(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag.""" """Test renaming an inline tag.
GIVEN an application
WHEN the user wants to delete an inline tag
THEN check the application deletes the tag
"""
app = test_application app = test_application
app._load_vault() app._load_vault()
mocker.patch( mocker.patch(
@@ -129,35 +159,45 @@ def test_delete_inline_tag(test_application, mocker, capsys) -> None:
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection", "obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["delete_inline_tag", "back"], side_effect=["delete_tag", "back"],
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_inline_tag", "obsidian_metadata.models.application.Questions.ask_existing_tag",
return_value="not_a_tag_in_vault", return_value="breakfast",
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert "WARNING | No notes were changed" in captured assert captured == Regex(r"SUCCESS +\| Deleted inline tag: breakfast in \d+ notes", re.DOTALL)
def test_delete_tag_2(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag.
GIVEN an application
WHEN the user wants to delete an inline tag that does not exist
THEN check the application does not update any notes
"""
app = test_application
app._load_vault()
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main", "obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["delete_metadata", KeyError], side_effect=["delete_metadata", KeyError],
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection", "obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["delete_inline_tag", "back"], side_effect=["delete_tag", "back"],
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_inline_tag", "obsidian_metadata.models.application.Questions.ask_existing_tag",
return_value="breakfast", return_value="not_a_tag_in_vault",
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS +\| Deleted inline tag: breakfast in \d+ notes", re.DOTALL) assert "WARNING | No notes were changed" in captured
def test_delete_key(test_application, mocker, capsys) -> None: def test_delete_key(test_application, mocker, capsys) -> None:
@@ -179,8 +219,8 @@ def test_delete_key(test_application, mocker, capsys) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert r"WARNING | No notes found with a key matching: \d{7}" in captured assert r"WARNING | No notes found with a key matching regex: \d{7}" in captured
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main", "obsidian_metadata.models.application.Questions.ask_application_main",
@@ -197,7 +237,7 @@ def test_delete_key(test_application, mocker, capsys) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS \| Deleted keys matching: d\\w\+ from \d+ notes", re.DOTALL) assert captured == Regex(r"SUCCESS \| Deleted keys matching: d\\w\+ from \d+ notes", re.DOTALL)
@@ -223,7 +263,7 @@ def test_delete_value(test_application, mocker, capsys) -> None:
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert r"WARNING | No notes found matching: area: \d{7}" in captured assert r"WARNING | No notes found matching: area: \d{7}" in captured
mocker.patch( mocker.patch(
@@ -244,8 +284,8 @@ def test_delete_value(test_application, mocker, capsys) -> None:
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert r"SUCCESS | Deleted value ^front\w+$ from key area in 4 notes" in captured assert captured == Regex(r"SUCCESS | Deleted value \^front\\w\+\$ from key area in \d+ notes")
def test_filter_notes(test_application, mocker, capsys) -> None: def test_filter_notes(test_application, mocker, capsys) -> None:
@@ -267,7 +307,7 @@ def test_filter_notes(test_application, mocker, capsys) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS +\| Loaded \d+ notes from \d+ total", re.DOTALL) assert captured == Regex(r"SUCCESS +\| Loaded \d+ notes from \d+ total", re.DOTALL)
assert "02 inline/inline 2.md" in captured assert "02 inline/inline 2.md" in captured
assert "03 mixed/mixed 1.md" not in captured assert "03 mixed/mixed 1.md" not in captured
@@ -322,7 +362,7 @@ def test_filter_clear(test_application, mocker, capsys) -> None:
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert "02 inline/inline 2.md" in captured assert "02 inline/inline 2.md" in captured
assert "03 mixed/mixed 1.md" in captured assert "03 mixed/mixed 1.md" in captured
assert "01 frontmatter/frontmatter 4.md" in captured assert "01 frontmatter/frontmatter 4.md" in captured
@@ -344,11 +384,14 @@ def test_inspect_metadata_all(test_application, mocker, capsys) -> None:
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex(r"type +│ article", re.DOTALL) assert captured == Regex(r"tags +│ bar ")
assert captured == Regex(r"status +│ new ")
assert captured == Regex(r"in_text_key +│ in-text value")
assert "#breakfast" in captured
def test_rename_inline_tag(test_application, mocker, capsys) -> None: def test_rename_tag(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag.""" """Test renaming an inline tag."""
app = test_application app = test_application
app._load_vault() app._load_vault()
@@ -358,10 +401,10 @@ def test_rename_inline_tag(test_application, mocker, capsys) -> None:
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection", "obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["rename_inline_tag", "back"], side_effect=["rename_tag", "back"],
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_inline_tag", "obsidian_metadata.models.application.Questions.ask_existing_tag",
return_value="not_a_tag", return_value="not_a_tag",
) )
mocker.patch( mocker.patch(
@@ -371,7 +414,7 @@ def test_rename_inline_tag(test_application, mocker, capsys) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert "No notes were changed" in captured assert "No notes were changed" in captured
mocker.patch( mocker.patch(
@@ -380,10 +423,10 @@ def test_rename_inline_tag(test_application, mocker, capsys) -> None:
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection", "obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["rename_inline_tag", "back"], side_effect=["rename_tag", "back"],
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_inline_tag", "obsidian_metadata.models.application.Questions.ask_existing_tag",
return_value="breakfast", return_value="breakfast",
) )
mocker.patch( mocker.patch(
@@ -393,7 +436,7 @@ def test_rename_inline_tag(test_application, mocker, capsys) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex(r"Renamed breakfast to new_tag in \d+ notes", re.DOTALL) assert captured == Regex(r"Renamed breakfast to new_tag in \d+ notes", re.DOTALL)
@@ -420,7 +463,7 @@ def test_rename_key(test_application, mocker, capsys) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert "WARNING | No notes were changed" in captured assert "WARNING | No notes were changed" in captured
mocker.patch( mocker.patch(
@@ -442,7 +485,7 @@ def test_rename_key(test_application, mocker, capsys) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex(r"Renamed tags to new_tags in \d+ notes", re.DOTALL) assert captured == Regex(r"Renamed tags to new_tags in \d+ notes", re.DOTALL)
@@ -472,7 +515,7 @@ def test_rename_value_fail(test_application, mocker, capsys) -> None:
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert "WARNING | No notes were changed" in captured assert "WARNING | No notes were changed" in captured
mocker.patch( mocker.patch(
@@ -497,7 +540,7 @@ def test_rename_value_fail(test_application, mocker, capsys) -> None:
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex( assert captured == Regex(
r"SUCCESS +\| Renamed 'area:frontmatter' to 'area:new_key' in \d+ notes", re.DOTALL r"SUCCESS +\| Renamed 'area:frontmatter' to 'area:new_key' in \d+ notes", re.DOTALL
) )
@@ -513,7 +556,7 @@ def test_review_no_changes(test_application, mocker, capsys) -> None:
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert "INFO | No changes to review" in captured assert "INFO | No changes to review" in captured
@@ -539,21 +582,26 @@ def test_review_changes(test_application, mocker, capsys) -> None:
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex(r".*Found \d+ changed notes in the vault", re.DOTALL) assert captured == Regex(r".*Found \d+ changed notes in the vault", re.DOTALL)
assert "- tags:" in captured assert "- tags:" in captured
assert "+ new_tags:" in captured assert "+ new_tags:" in captured
def test_transpose_metadata(test_application, mocker, capsys) -> None: def test_transpose_metadata_1(test_application, mocker, capsys) -> None:
"""Transpose metadata.""" """Transpose metadata.
GIVEN a test application
WHEN the user wants to transpose all inline metadata to frontmatter
THEN the metadata is transposed
"""
app = test_application app = test_application
app._load_vault() app._load_vault()
assert app.vault.metadata.inline_metadata["inline_key"] == ["inline_key_value"] assert app.vault.inline_meta["inline_key"] == ["inline_key_value"]
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main", "obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["transpose_metadata", KeyError], side_effect=["reorganize_metadata", KeyError],
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection", "obsidian_metadata.models.application.Questions.ask_selection",
@@ -561,18 +609,27 @@ def test_transpose_metadata(test_application, mocker, capsys) -> None:
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
assert app.vault.metadata.inline_metadata == {}
assert app.vault.metadata.frontmatter["inline_key"] == ["inline_key_value"] assert app.vault.inline_meta == {}
captured = remove_ansi(capsys.readouterr().out) assert app.vault.frontmatter["inline_key"] == ["inline_key_value"]
captured = strip_ansi(capsys.readouterr().out)
assert "SUCCESS | Transposed Inline Metadata to Frontmatter in 5 notes" in captured assert "SUCCESS | Transposed Inline Metadata to Frontmatter in 5 notes" in captured
def test_transpose_metadata_2(test_application, mocker) -> None:
"""Transpose metadata.
GIVEN a test application
WHEN the user wants to transpose all frontmatter to inline metadata
THEN the metadata is transposed
"""
app = test_application app = test_application
app._load_vault() app._load_vault()
assert app.vault.metadata.frontmatter["date_created"] == ["2022-12-21", "2022-12-22"] assert app.vault.frontmatter["date_created"] == ["2022-12-21", "2022-12-22"]
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main", "obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["transpose_metadata", KeyError], side_effect=["reorganize_metadata", KeyError],
) )
mocker.patch( mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection", "obsidian_metadata.models.application.Questions.ask_selection",
@@ -580,8 +637,8 @@ def test_transpose_metadata(test_application, mocker, capsys) -> None:
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
assert app.vault.metadata.inline_metadata["date_created"] == ["2022-12-21", "2022-12-22"] assert app.vault.inline_meta["date_created"] == ["2022-12-21", "2022-12-22"]
assert app.vault.metadata.frontmatter == {} assert app.vault.frontmatter == {}
def test_vault_backup(test_application, mocker, capsys) -> None: def test_vault_backup(test_application, mocker, capsys) -> None:
@@ -599,7 +656,7 @@ def test_vault_backup(test_application, mocker, capsys) -> None:
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex( assert captured == Regex(
r"SUCCESS +\| Vault backed up to:[-\w\d\/\s]+application\.bak", re.DOTALL r"SUCCESS +\| Vault backed up to:[-\w\d\/\s]+application\.bak", re.DOTALL
) )
@@ -622,5 +679,5 @@ def test_vault_delete(test_application, mocker, capsys, tmp_path) -> None:
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = strip_ansi(capsys.readouterr().out)
assert captured == Regex(r"SUCCESS +\| Backup deleted", re.DOTALL) assert captured == Regex(r"SUCCESS +\| Backup deleted", re.DOTALL)

View File

@@ -1,9 +1,13 @@
# type: ignore # type: ignore
"""Test obsidian-metadata CLI.""" """Test obsidian-metadata CLI."""
import shutil
from pathlib import Path
from typer.testing import CliRunner from typer.testing import CliRunner
from obsidian_metadata.cli import app from obsidian_metadata.cli import app
from tests.helpers import Regex, strip_ansi
from .helpers import KeyInputs, Regex # noqa: F401 from .helpers import KeyInputs, Regex # noqa: F401
@@ -14,19 +18,28 @@ def test_version() -> None:
"""Test printing version and then exiting.""" """Test printing version and then exiting."""
result = runner.invoke(app, ["--version"]) result = runner.invoke(app, ["--version"])
assert result.exit_code == 0 assert result.exit_code == 0
assert result.output == Regex(r"obsidian_metadata: v\d+\.\d+\.\d+$") assert "obsidian_metadata: v" in result.output
def test_application(test_vault, tmp_path) -> None: def test_application(tmp_path) -> None:
"""Test the application.""" """Test the application."""
vault_path = test_vault source_dir = Path(__file__).parent / "fixtures" / "test_vault"
dest_dir = Path(tmp_path / "vault")
if not source_dir.exists():
raise FileNotFoundError(f"Sample vault not found: {source_dir}")
shutil.copytree(source_dir, dest_dir)
config_path = tmp_path / "config.toml" config_path = tmp_path / "config.toml"
result = runner.invoke( result = runner.invoke(
app, app,
["--vault-path", vault_path, "--config-file", config_path], ["--vault-path", dest_dir, "--config-file", config_path],
# input=KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.ENTER, # noqa: ERA001 # input=KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.ENTER, # noqa: ERA001
) )
output = strip_ansi(result.output)
banner = r""" banner = r"""
___ _ _ _ _ ___ _ _ _ _
/ _ \| |__ ___(_) __| (_) __ _ _ __ / _ \| |__ ___(_) __| (_) __ _ _ __
@@ -39,5 +52,28 @@ def test_application(test_vault, tmp_path) -> None:
|_| |_|\___|\__\__,_|\__,_|\__,_|\__\__,_| |_| |_|\___|\__\__,_|\__,_|\__,_|\__\__,_|
""" """
assert banner in result.output assert banner in output
assert output == Regex(r"SUCCESS \| Loaded \d+ notes from \d+ total notes")
assert result.exit_code == 1 assert result.exit_code == 1
def test_export_template(tmp_path) -> None:
"""Test the export template command."""
source_dir = Path(__file__).parent / "fixtures" / "test_vault"
dest_dir = Path(tmp_path / "vault")
if not source_dir.exists():
raise FileNotFoundError(f"Sample vault not found: {source_dir}")
shutil.copytree(source_dir, dest_dir)
config_path = tmp_path / "config.toml"
export_path = tmp_path / "export_template.csv"
result = runner.invoke(
app,
["--vault-path", dest_dir, "--config-file", config_path, "--export-template", export_path],
)
assert "SUCCESS | Exported metadata to" in result.output
assert result.exit_code == 0
assert export_path.exists()

View File

@@ -36,7 +36,7 @@ def test_vault_path_errors(tmp_path, capsys) -> None:
assert "Vault path not found" in captured.out assert "Vault path not found" in captured.out
with pytest.raises(typer.Exit): with pytest.raises(typer.Exit):
Config(config_path=config_file, vault_path=Path("tests/fixtures/sample_note.md")) Config(config_path=config_file, vault_path=Path("tests/fixtures/test_vault/sample_note.md"))
captured = capsys.readouterr() captured = capsys.readouterr()
assert "Vault path is not a directory" in captured.out assert "Vault path is not a directory" in captured.out
@@ -103,6 +103,8 @@ def test_no_config_no_vault(tmp_path, mocker) -> None:
["Vault 1"] # Name of the vault. ["Vault 1"] # Name of the vault.
# Path to your obsidian vault # Path to your obsidian vault
# Note for Windows users: Windows paths must use `\\` as the path separator due to a limitation with how TOML parses strings.
# Example: "C:\\Users\\username\\Documents\\Obsidian"
path = "{str(fake_vault)}" path = "{str(fake_vault)}"
# Folders within the vault to ignore when indexing metadata # Folders within the vault to ignore when indexing metadata

View File

@@ -9,6 +9,13 @@ import pytest
from obsidian_metadata._config import Config from obsidian_metadata._config import Config
from obsidian_metadata.models.application import Application from obsidian_metadata.models.application import Application
CONFIG_1 = """
["Test Vault"]
exclude_paths = [".git", ".obsidian", "ignore_folder"]
insert_location = "TOP"
path = "TMPDIR_VAULT_PATH"
"""
def remove_all(root: Path): def remove_all(root: Path):
"""Remove all files and directories in a directory.""" """Remove all files and directories in a directory."""
@@ -25,7 +32,7 @@ def remove_all(root: Path):
@pytest.fixture() @pytest.fixture()
def sample_note(tmp_path) -> Path: def sample_note(tmp_path) -> Path:
"""Fixture which creates a temporary note file.""" """Fixture which creates a temporary note file."""
source_file: Path = Path("tests/fixtures/test_vault/test1.md") source_file: Path = Path("tests/fixtures/test_vault/sample_note.md")
if not source_file.exists(): if not source_file.exists():
raise FileNotFoundError(f"Original file not found: {source_file}") raise FileNotFoundError(f"Original file not found: {source_file}")
@@ -95,10 +102,16 @@ def test_vault(tmp_path) -> Path:
raise FileNotFoundError(f"Sample vault not found: {source_dir}") raise FileNotFoundError(f"Sample vault not found: {source_dir}")
shutil.copytree(source_dir, dest_dir) shutil.copytree(source_dir, dest_dir)
yield dest_dir config_path = Path(tmp_path / "config.toml")
config_path.write_text(CONFIG_1.replace("TMPDIR_VAULT_PATH", str(dest_dir)))
config = Config(config_path=config_path)
vault_config = config.vaults[0]
yield vault_config
# after test - remove fixtures # after test - remove fixtures
shutil.rmtree(dest_dir) shutil.rmtree(dest_dir)
config_path.unlink()
if backup_dir.exists(): if backup_dir.exists():
shutil.rmtree(backup_dir) shutil.rmtree(backup_dir)

44
tests/fixtures/CP1250.md vendored Normal file
View File

@@ -0,0 +1,44 @@
---
date_created: 2022-12-22 # confirm dates are translated to strings
tags:
- foo
- bar
frontmatter1: foo
frontmatter2: ["bar", "baz", "qux"]
??: ??
# Nested lists are not supported
# invalid:
# invalid:
# - invalid
# - invalid2
french1: "Voix ambigu<67> d'un cour qui, au z<>phyr, pr<70>fere les jattes de kiwis"
---
# Heading 1
inline1:: foo
inline1::bar baz
**inline2**:: [[foo]]
_inline3_:: value
??::??
key with space:: foo
french2:: Voix ambigu<67> d'un cour qui, au z<>phyr, pr<70>fere les jattes de kiwis.
> inline4:: foo
inline5::
foo bar [intext1:: foo] baz `#invalid` qux (intext2:: foo) foobar. #tag1 Foo bar #tag2 baz qux. [[link]]
The quick brown fox jumped over the lazy dog.
# tag3
---
## invalid: invalid
```python
invalid:: invalid
#invalid
```

View File

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

View File

@@ -1,39 +0,0 @@
---
date_created: 2022-12-22
tags:
- 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
---
area:: mixed
date_modified:: 2022-12-22
status:: new
type:: book
inline_key:: inline_key_value
type:: [[article]]
tags:: from_inline_metadata
**bold_key**:: **bold** key value
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, [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
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
#food/fruit/orange
#dinner #breakfast
#brunch

View File

@@ -0,0 +1,44 @@
---
date_created: 2022-12-22 # confirm dates are translated to strings
tags:
- foo
- bar
frontmatter1: foo
frontmatter2: ["bar", "baz", "qux"]
🌱: 🌿
# Nested lists are not supported
# invalid:
# invalid:
# - invalid
# - invalid2
french1: "Voix ambiguë d'un cœur qui, au zéphyr, préfère les jattes de kiwis"
---
# Heading 1
inline1:: foo
inline1::bar baz
**inline2**:: [[foo]]
_inline3_:: value
🌱::🌿
key with space:: foo
french2:: Voix ambiguë d'un cœur qui, au zéphyr, préfère les jattes de kiwis.
> inline4:: foo
inline5::
foo bar [intext1:: foo] baz `#invalid` qux (intext2:: foo) foobar. #tag1 Foo bar #tag2 baz qux. [[link]]
The quick brown fox jumped over the lazy dog.
# tag3
---
## invalid: invalid
```python
invalid:: invalid
#invalid
```

View File

@@ -1,48 +0,0 @@
---
date_created: 2022-12-22
tags:
- shared_tag
- frontmatter_tag1
- frontmatter_tag2
-
- 📅/frontmatter_tag3
frontmatter_Key1: author name
frontmatter_Key2: ["article", "note"]
shared_key1:
- shared_key1_value
- shared_key1_value3
shared_key2: shared_key2_value1
---
#inline_tag_top1 #inline_tag_top2
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
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
#ffffff
# This is sample text with tags and metadata
#in_codeblock_tag1
#ffffff;
codeblock_key:: some text
in_codeblock_key:: in_codeblock_value
The quick brown fox jumped over the #in_codeblock_tag2
```
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab `this is #inline_code_tag1` 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? `this is #inline_code_tag2` 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 pari
bottom_key1:: bottom_key1_value
bottom_key2:: bottom_key2_value
#inline_tag_bottom1
#inline_tag_bottom2
#shared_tag

View File

@@ -22,7 +22,7 @@ class KeyInputs:
THREE = "3" THREE = "3"
def remove_ansi(text) -> str: def strip_ansi(text) -> str:
"""Remove ANSI escape sequences from a string. """Remove ANSI escape sequences from a string.
Args: Args:

View File

@@ -1,760 +1,209 @@
# type: ignore # type: ignore
"""Test metadata.py.""" """Test the InlineField class."""
from pathlib import Path
import pytest import pytest
from obsidian_metadata.models.enums import MetadataType from obsidian_metadata.models.enums import MetadataType, Wrapping
from obsidian_metadata.models.metadata import ( from obsidian_metadata.models.metadata import InlineField, dict_to_yaml
Frontmatter,
InlineMetadata,
InlineTags,
VaultMetadata,
)
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"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 2", "tag 1", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"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:
- tag_1
- tag_2
-
- 📅/tag_3
frontmatter_Key1: "frontmatter_Key1_value"
frontmatter_Key2: ["note", "article"]
shared_key1: "shared_key1_value"
---
more content
---
horizontal: rule
---
"""
INLINE_CONTENT = """\
repeated_key:: repeated_key_value1
#inline_tag_top1,#inline_tag_top2
**bold_key1**:: bold_key1_value
**bold_key2:: bold_key2_value**
link_key:: [[link_key_value]]
tag_key:: #tag_key_value
emoji_📅_key:: emoji_📅_key_value
**#bold_tag**
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. [in_text_key1:: in_text_key1_value] Ut enim ad minim veniam, quis nostrud exercitation [in_text_key2:: in_text_key2_value] ullamco laboris nisi ut aliquip ex ea commodo consequat. #in_text_tag Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
```python
#ffffff
# This is sample text [no_key:: value]with tags and metadata
#in_codeblock_tag1
#ffffff;
in_codeblock_key:: in_codeblock_value
The quick brown fox jumped over the #in_codeblock_tag2
```
repeated_key:: repeated_key_value2
"""
def test_frontmatter_create() -> None: def test_dict_to_yaml_1():
"""Test frontmatter creation.""" """Test dict_to_yaml() function.
frontmatter = Frontmatter(INLINE_CONTENT)
assert frontmatter.dict == {}
frontmatter = Frontmatter(FRONTMATTER_CONTENT) GIVEN a dictionary
assert frontmatter.dict == { WHEN values contain lists
"frontmatter_Key1": ["frontmatter_Key1_value"], THEN confirm the output is not sorted
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.dict_original == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
def test_frontmatter_create_error() -> None:
"""Test frontmatter creation error.
GIVEN frontmatter content
WHEN frontmatter is invalid
THEN raise ValueError
""" """
fn = """--- test_dict = {"k2": ["v1", "v2"], "k1": ["v1", "v2"]}
tags: tag assert dict_to_yaml(test_dict) == "k2:\n - v1\n - v2\nk1:\n - v1\n - v2\n"
invalid = = "content"
---
def test_dict_to_yaml_2():
"""Test dict_to_yaml() function.
GIVEN a dictionary
WHEN values contain lists and sort_keys is True
THEN confirm the output is sorted
""" """
with pytest.raises(AttributeError): test_dict = {"k2": ["v1", "v2"], "k1": ["v1", "v2"]}
Frontmatter(fn) assert dict_to_yaml(test_dict, sort_keys=True) == "k1:\n - v1\n - v2\nk2:\n - v1\n - v2\n"
def test_frontmatter_contains() -> None: def test_dict_to_yaml_3():
"""Test frontmatter contains.""" """Test dict_to_yaml() function.
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("frontmatter_Key1") is True GIVEN a dictionary
assert frontmatter.contains("frontmatter_Key2", "article") is True WHEN values contain a list with a single value
assert frontmatter.contains("frontmatter_Key3") is False THEN confirm single-value lists are converted to strings
assert frontmatter.contains("frontmatter_Key2", "no value") is False """
test_dict = {"k2": ["v1"], "k1": ["v1", "v2"]}
assert dict_to_yaml(test_dict, sort_keys=True) == "k1:\n - v1\n - v2\nk2: v1\n"
assert frontmatter.contains(r"\d$", is_regex=True) is True
assert frontmatter.contains(r"^\d", is_regex=True) is False
assert frontmatter.contains("key", r"_\d", is_regex=True) is False
assert frontmatter.contains("key", r"\w\d_", is_regex=True) is True
def test_init_1():
"""Test creating an InlineField object.
def test_frontmatter_add() -> None: GIVEN an inline tag
"""Test frontmatter add.""" WHEN an InlineField object is created
frontmatter = Frontmatter(FRONTMATTER_CONTENT) THEN confirm the object's attributes match the expected values
"""
assert frontmatter.add("frontmatter_Key1") is False obj = InlineField(
assert frontmatter.add("added_key") is True meta_type=MetadataType.TAGS,
assert frontmatter.dict == { key=None,
"added_key": [], value="tag1",
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key", "added_value") is True
assert frontmatter.dict == {
"added_key": ["added_value"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key", "added_value_2") is True
assert frontmatter.dict == {
"added_key": ["added_value", "added_value_2"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key", ["added_value_3", "added_value_4"]) is True
assert frontmatter.dict == {
"added_key": ["added_value", "added_value_2", "added_value_3", "added_value_4"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key2", ["added_value_1", "added_value_2"]) is True
assert frontmatter.dict == {
"added_key": ["added_value", "added_value_2", "added_value_3", "added_value_4"],
"added_key2": ["added_value_1", "added_value_2"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key3", "added_value_1") is True
assert frontmatter.dict == {
"added_key": ["added_value", "added_value_2", "added_value_3", "added_value_4"],
"added_key2": ["added_value_1", "added_value_2"],
"added_key3": ["added_value_1"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key3", "added_value_1") is False
def test_frontmatter_rename() -> None:
"""Test frontmatter rename."""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.dict == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.rename("no key", "new key") is False
assert frontmatter.rename("tags", "no tag", "new key") is False
assert frontmatter.has_changes() is False
assert frontmatter.rename("tags", "tag_2", "new tag") is True
assert frontmatter.dict["tags"] == ["new tag", "tag_1", "📅/tag_3"]
assert frontmatter.rename("tags", "old_tags") is True
assert frontmatter.dict["old_tags"] == ["new tag", "tag_1", "📅/tag_3"]
assert "tags" not in frontmatter.dict
assert frontmatter.has_changes() is True
def test_frontmatter_delete() -> None:
"""Test Frontmatter delete method."""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.dict == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.delete("no key") is False
assert frontmatter.delete("tags", "no value") is False
assert frontmatter.delete(r"\d{3}") is False
assert frontmatter.has_changes() is False
assert frontmatter.delete("tags", "tag_2") is True
assert frontmatter.dict["tags"] == ["tag_1", "📅/tag_3"]
assert frontmatter.delete("tags") is True
assert "tags" not in frontmatter.dict
assert frontmatter.has_changes() is True
assert frontmatter.delete("shared_key1", r"\w+") is True
assert frontmatter.dict["shared_key1"] == []
assert frontmatter.delete(r"\w.tter") is True
assert frontmatter.dict == {"shared_key1": []}
def test_frontmatter_yaml_conversion():
"""Test Frontmatter to_yaml method."""
new_frontmatter: str = """\
tags:
- tag_1
- tag_2
- 📅/tag_3
frontmatter_Key1: frontmatter_Key1_value
frontmatter_Key2:
- article
- note
shared_key1: shared_key1_value
"""
new_frontmatter_sorted: str = """\
frontmatter_Key1: frontmatter_Key1_value
frontmatter_Key2:
- article
- note
shared_key1: shared_key1_value
tags:
- tag_1
- tag_2
- 📅/tag_3
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.to_yaml() == new_frontmatter
assert frontmatter.to_yaml(sort_keys=True) == new_frontmatter_sorted
def test_inline_metadata_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)
assert inline.dict == {}
inline = InlineMetadata(INLINE_CONTENT)
assert inline.dict == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.dict_original == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
def test_inline_metadata_delete() -> None:
"""Test inline metadata delete."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.dict == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.delete("no key") is False
assert inline.delete("repeated_key", "no value") is False
assert inline.has_changes() is False
assert inline.delete("repeated_key", "repeated_key_value1") is True
assert inline.dict["repeated_key"] == ["repeated_key_value2"]
assert inline.delete("repeated_key") is True
assert "repeated_key" not in inline.dict
assert inline.has_changes() is True
assert inline.delete(r"\d{3}") is False
assert inline.delete(r"bold_key\d") is True
assert inline.dict == {
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"tag_key": ["tag_key_value"],
}
assert inline.delete("emoji_📅_key", ".*📅.*") is True
assert inline.dict == {
"emoji_📅_key": [],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"tag_key": ["tag_key_value"],
}
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)
tags.metadata_key
assert tags.list == []
tags = InlineTags(INLINE_CONTENT)
assert tags.list == [
"bold_tag",
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"tag_key_value",
]
assert tags.list_original == [
"bold_tag",
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"tag_key_value",
]
def test_inline_tags_delete() -> None:
"""Test inline tags delete."""
tags = InlineTags(INLINE_CONTENT)
assert tags.list == [
"bold_tag",
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"tag_key_value",
]
assert tags.delete("no tag") is False
assert tags.has_changes() is False
assert tags.delete("bold_tag") is True
assert tags.list == [
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"tag_key_value",
]
assert tags.has_changes() is True
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 obj.meta_type == MetadataType.TAGS
assert vm.contains(area=MetadataType.FRONTMATTER, key="1$", is_regex=True) is True assert obj.key is None
assert vm.contains(area=MetadataType.FRONTMATTER, key=r"\d\d", is_regex=True) is False assert obj.value == "tag1"
assert obj.normalized_value == "tag1"
assert vm.contains(area=MetadataType.INLINE, key="no_key") is False assert obj.wrapping == Wrapping.NONE
assert vm.contains(area=MetadataType.INLINE, key="key1") is True assert obj.clean_key is None
assert vm.contains(area=MetadataType.INLINE, key="key2", value="value3") is True assert obj.normalized_key is None
assert vm.contains(area=MetadataType.INLINE, key="key2", value="none") is False assert not obj.key_open
assert vm.contains(area=MetadataType.INLINE, key="1$", is_regex=True) is True assert not obj.key_close
assert vm.contains(area=MetadataType.INLINE, key=r"\d\d", is_regex=True) is False assert obj.is_changed 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: def test_init_2():
"""Test delete method.""" """Test creating an InlineField object.
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 GIVEN an inline key/value pair
assert vm.delete("tags", "no value") is False WHEN an InlineField object is created
assert vm.delete("tags", "tag 2") is True THEN confirm the object's attributes match the expected values
assert vm.dict["tags"] == ["tag 1", "tag 3"] """
assert vm.delete("tags") is True obj = InlineField(meta_type=MetadataType.INLINE, key="key", value="value")
assert "tags" not in vm.dict assert obj.meta_type == MetadataType.INLINE
assert obj.key == "key"
assert obj.value == "value"
assert obj.normalized_value == "value"
assert obj.wrapping == Wrapping.NONE
assert obj.clean_key == "key"
assert obj.normalized_key == "key"
assert not obj.key_open
assert not obj.key_close
assert obj.is_changed is False
obj = InlineField(
meta_type=MetadataType.INLINE,
key="key",
value="value",
wrapping=Wrapping.PARENS,
)
assert obj.meta_type == MetadataType.INLINE
assert obj.key == "key"
assert obj.value == "value"
assert obj.normalized_value == "value"
assert obj.wrapping == Wrapping.PARENS
assert obj.clean_key == "key"
assert obj.normalized_key == "key"
assert not obj.key_open
assert not obj.key_close
assert obj.is_changed is False
obj = InlineField(
meta_type=MetadataType.INLINE,
key="**key**",
value="value",
wrapping=Wrapping.BRACKETS,
)
assert obj.meta_type == MetadataType.INLINE
assert obj.key == "**key**"
assert obj.value == "value"
assert obj.normalized_value == "value"
assert obj.wrapping == Wrapping.BRACKETS
assert obj.clean_key == "key"
assert obj.normalized_key == "key"
assert obj.key_open == "**"
assert obj.key_close == "**"
assert obj.is_changed is False
def test_vault_metadata_rename() -> None: @pytest.mark.parametrize(
"""Test rename method.""" (
vm = VaultMetadata() "original",
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=METADATA) "cleaned",
assert vm.dict == { "normalized",
"frontmatter_Key1": ["author name"], "key_open",
"frontmatter_Key2": ["article", "note"], "key_close",
"intext_key": ["intext_key_value"], ),
"shared_key1": ["shared_key1_value"], [
"shared_key2": ["shared_key2_value"], ("foo", "foo", "foo", "", ""),
"tags": ["tag 1", "tag 2", "tag 3"], ("🌱/🌿", "🌱/🌿", "🌱/🌿", "", ""),
"top_key1": ["top_key1_value"], ("FOO 1", "FOO 1", "foo-1", "", ""),
"top_key2": ["top_key2_value"], ("**key foo**", "key foo", "key-foo", "**", "**"),
"top_key3": ["top_key3_value"], ("## KEY", "KEY", "key", "## ", ""),
} ],
)
def test_init_3(original, cleaned, normalized, key_open, key_close):
"""Test creating an InlineField object.
assert vm.rename("no key", "new key") is False GIVEN an InlineField object is created
assert vm.rename("tags", "no tag", "new key") is False WHEN the key needs to be normalized
assert vm.rename("tags", "tag 2", "new tag") is True THEN confirm clean_key() returns the expected value
assert vm.dict["tags"] == ["new tag", "tag 1", "tag 3"] """
assert vm.rename("tags", "old_tags") is True obj = InlineField(meta_type=MetadataType.INLINE, key=original, value="value")
assert vm.dict["old_tags"] == ["new tag", "tag 1", "tag 3"] assert obj.clean_key == cleaned
assert "tags" not in vm.dict assert obj.normalized_key == normalized
assert obj.key_open == key_open
assert obj.key_close == key_close
@pytest.mark.parametrize(
("original", "normalized"),
[("foo", "foo"), ("🌱/🌿", "🌱/🌿"), (" value ", "value"), (" ", "-"), ("", "-")],
)
def test_init_4(original, normalized):
"""Test creating an InlineField object.
GIVEN an InlineField object is created
WHEN the value needs to be normalized
THEN create the normalized_value attribute
"""
obj = InlineField(meta_type=MetadataType.INLINE, key="key", value=original)
assert obj.value == original
assert obj.normalized_value == normalized
def test_inline_field_init_5():
"""Test updating the is_changed attribute.
GIVEN creating an object
WHEN is_changed set to True at init
THEN confirm is_changed is True
"""
obj = InlineField(meta_type=MetadataType.TAGS, key="key", value="tag1", is_changed=True)
assert obj.is_changed is True
def test_inline_field_init_6():
"""Test updating the is_changed attribute.
GIVEN creating an object
WHEN is_changed set to True at after init
THEN confirm is_changed is True
"""
obj = InlineField(meta_type=MetadataType.TAGS, key="key", value="tag1", is_changed=False)
assert obj.is_changed is False
obj.is_changed = True
assert obj.is_changed is True
def test_inline_field_init_4():
"""Test updating the is_changed attribute.
GIVEN creating an object
WHEN key_open and key_close are set after init
THEN confirm they are set correctly
"""
obj = InlineField(
meta_type=MetadataType.INLINE,
key="_key_",
value="value",
is_changed=False,
)
assert obj.key_open == "_"
assert obj.key_close == "_"
obj.key_open = "**"
obj.key_close = "**"
assert obj.key_open == "**"
assert obj.key_close == "**"

View File

@@ -0,0 +1,236 @@
# type: ignore
"""Test notes.py."""
from pathlib import Path
import pytest
import typer
from obsidian_metadata.models.enums import MetadataType
from obsidian_metadata.models.exceptions import FrontmatterError
from obsidian_metadata.models.metadata import InlineField
from obsidian_metadata.models.notes import Note
def test_note_not_exists() -> None:
"""Test target not found.
GIVEN a path to a non-existent file
WHEN a Note object is created pointing to that file
THEN a typer.Exit exception is raised
"""
with pytest.raises(typer.Exit):
Note(note_path="nonexistent_file.md")
def test_create_note_1(sample_note):
"""Test creating a note object.
GIVEN a path to a markdown file
WHEN a Note object is created pointing to that file
THEN the Note object is created
"""
note = Note(note_path=sample_note, dry_run=True)
assert note.note_path == Path(sample_note)
assert note.dry_run is True
assert note.encoding == "utf_8"
assert len(note.metadata) == 22
with sample_note.open():
content = sample_note.read_text()
assert note.file_content == content
assert note.original_file_content == content
def test_create_note_2(tmp_path) -> None:
"""Test creating a note object.
GIVEN a text file with invalid frontmatter
WHEN the note is initialized
THEN a typer exit is raised
"""
note_path = Path(tmp_path) / "broken_frontmatter.md"
note_path.touch()
note_path.write_text(
"""---
tags:
invalid = = "content"
---
"""
)
with pytest.raises(typer.Exit):
Note(note_path=note_path)
def test_create_note_3(tmp_path) -> None:
"""Test creating a note object.
GIVEN a text file with invalid frontmatter
WHEN the note is initialized
THEN a typer exit is raised
"""
note_path = Path(tmp_path) / "broken_frontmatter.md"
note_path.touch()
note_path.write_text(
"""---
nested1:
nested2: "content"
nested3:
- "content"
- "content"
---
"""
)
with pytest.raises(typer.Exit):
Note(note_path=note_path)
def test_create_note_6(tmp_path):
"""Test creating a note object.
GIVEN a text file
WHEN there is no content in the file
THEN a note is returned with no metadata or content
"""
note_path = Path(tmp_path) / "empty_file.md"
note_path.touch()
note = Note(note_path=note_path)
assert note.note_path == note_path
assert not note.file_content
assert not note.original_file_content
assert note.metadata == []
def test__grab_metadata_1(tmp_path):
"""Test the _grab_metadata method.
GIVEN a text file
WHEN there is frontmatter
THEN the frontmatter is returned in the metadata list
"""
note_path = Path(tmp_path) / "test_file.md"
note_path.touch()
note_path.write_text(
"""
---
key1: value1
key2: 2022-12-22
key3:
- value3
- value4
key4:
key5: "value5"
---
"""
)
note = Note(note_path=note_path)
assert sorted(note.metadata, key=lambda x: (x.key, x.value)) == [
InlineField(meta_type=MetadataType.FRONTMATTER, key="key1", value="value1"),
InlineField(meta_type=MetadataType.FRONTMATTER, key="key2", value="2022-12-22"),
InlineField(meta_type=MetadataType.FRONTMATTER, key="key3", value="value3"),
InlineField(meta_type=MetadataType.FRONTMATTER, key="key3", value="value4"),
InlineField(meta_type=MetadataType.FRONTMATTER, key="key4", value="None"),
InlineField(meta_type=MetadataType.FRONTMATTER, key="key5", value="value5"),
]
def test__grab_metadata_2(tmp_path):
"""Test the _grab_metadata method.
GIVEN a text file
WHEN there is inline metadata
THEN the inline metadata is returned in the metadata list
"""
note_path = Path(tmp_path) / "test_file.md"
note_path.touch()
note_path.write_text(
"""
key1::value1
key2::2022-12-22
foo [key3::value3] bar
key4::value4
foo (key4::value) bar
key5::value5
key6:: `value6`
`key7::value7`
`key8`::`value8`
"""
)
note = Note(note_path=note_path)
assert sorted(note.metadata, key=lambda x: (x.key, x.value)) == [
InlineField(meta_type=MetadataType.INLINE, key="`key7", value="value7`"),
InlineField(meta_type=MetadataType.INLINE, key="`key8`", value="`value8`"),
InlineField(meta_type=MetadataType.INLINE, key="key1", value="value1"),
InlineField(meta_type=MetadataType.INLINE, key="key2", value="2022-12-22"),
InlineField(meta_type=MetadataType.INLINE, key="key3", value="value3"),
InlineField(meta_type=MetadataType.INLINE, key="key4", value="value"),
InlineField(meta_type=MetadataType.INLINE, key="key4", value="value4"),
InlineField(meta_type=MetadataType.INLINE, key="key5", value="value5"),
InlineField(meta_type=MetadataType.INLINE, key="key6", value=" `value6`"),
]
def test__grab_metadata_3(tmp_path):
"""Test the _grab_metadata method.
GIVEN a text file
WHEN there are tags
THEN the tags are returned in the metadata list
"""
note_path = Path(tmp_path) / "test_file.md"
note_path.touch()
note_path.write_text("#tag1\n#tag2")
note = Note(note_path=note_path)
assert sorted(note.metadata, key=lambda x: x.value) == [
InlineField(meta_type=MetadataType.TAGS, key=None, value="tag1"),
InlineField(meta_type=MetadataType.TAGS, key=None, value="tag2"),
]
def test__grab_metadata_4(tmp_path):
"""Test the _grab_metadata method.
GIVEN a text file
WHEN there are tags, frontmatter, and inline metadata
THEN all metadata is returned
"""
note_path = Path(tmp_path) / "test_file.md"
note_path.touch()
note_path.write_text(
"""\
---
key1: value1
---
key2::value2
#tag1\n#tag2"""
)
note = Note(note_path=note_path)
assert sorted(note.metadata, key=lambda x: x.value) == [
InlineField(meta_type=MetadataType.TAGS, key=None, value="tag1"),
InlineField(meta_type=MetadataType.TAGS, key=None, value="tag2"),
InlineField(meta_type=MetadataType.FRONTMATTER, key="key1", value="value1"),
InlineField(meta_type=MetadataType.INLINE, key="key2", value="value2"),
]
def test__grab_metadata_5(tmp_path):
"""Test the _grab_metadata method.
GIVEN a text file
WHEN invalid metadata is present
THEN raise a FrontmatterError
"""
note_path = Path(tmp_path) / "broken_frontmatter.md"
note_path.touch()
note_path.write_text(
"""---
tags:
invalid = = "content"
---
"""
)
with pytest.raises(typer.Exit):
Note(note_path=note_path)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

379
tests/parsers_test.py Normal file
View File

@@ -0,0 +1,379 @@
# type: ignore
"""Test the parsers module."""
import re
import pytest
from obsidian_metadata.models.enums import Wrapping
from obsidian_metadata.models.parsers import Parser
P = Parser()
def test_identify_internal_link_1():
"""Test the internal_link attribute.
GIVEN a string with an external link
WHEN the internal_link attribute is called within a regex
THEN the external link is not found
"""
assert re.findall(P.internal_link, "[link](https://example.com/somepage.html)") == []
def test_identify_internal_link_2():
"""Test the internal_link attribute.
GIVEN a string with out any links
WHEN the internal_link attribute is called within a regex
THEN no links are found
"""
assert re.findall(P.internal_link, "foo bar baz") == []
def test_identify_internal_link_3():
"""Test the internal_link attribute.
GIVEN a string with an internal link
WHEN the internal_link attribute is called within a regex
THEN the internal link is found
"""
assert re.findall(P.internal_link, "[[internal_link]]") == ["[[internal_link]]"]
assert re.findall(P.internal_link, "[[internal_link|text]]") == ["[[internal_link|text]]"]
assert re.findall(P.internal_link, "[[test/Main.md]]") == ["[[test/Main.md]]"]
assert re.findall(P.internal_link, "[[%Man &Machine + Mind%]]") == ["[[%Man &Machine + Mind%]]"]
assert re.findall(P.internal_link, "[[Hello \\| There]]") == ["[[Hello \\| There]]"]
assert re.findall(P.internal_link, "[[\\||Yes]]") == ["[[\\||Yes]]"]
assert re.findall(P.internal_link, "[[test/Main|Yes]]") == ["[[test/Main|Yes]]"]
assert re.findall(P.internal_link, "[[2020#^14df]]") == ["[[2020#^14df]]"]
assert re.findall(P.internal_link, "!foo[[bar]]baz") == ["[[bar]]"]
assert re.findall(P.internal_link, "[[]]") == ["[[]]"]
def test_return_frontmatter_1():
"""Test the return_frontmatter method.
GIVEN a string with frontmatter
WHEN the return_frontmatter method is called
THEN the frontmatter is returned
"""
content = """
---
key: value
---
# Hello World
"""
assert P.return_frontmatter(content) == "---\nkey: value\n---"
def test_return_frontmatter_2():
"""Test the return_frontmatter method.
GIVEN a string without frontmatter
WHEN the return_frontmatter method is called
THEN None is returned
"""
content = """
# Hello World
---
key: value
---
"""
assert P.return_frontmatter(content) is None
def test_return_frontmatter_3():
"""Test the return_frontmatter method.
GIVEN a string with frontmatter
WHEN the return_frontmatter method is called with data_only=True
THEN the frontmatter is returned
"""
content = """
---
key: value
key2: value2
---
# Hello World
"""
assert P.return_frontmatter(content, data_only=True) == "key: value\nkey2: value2"
def test_return_frontmatter_4():
"""Test the return_frontmatter method.
GIVEN a string without frontmatter
WHEN the return_frontmatter method is called with data_only=True
THEN None is returned
"""
content = """
# Hello World
---
key: value
---
"""
assert P.return_frontmatter(content, data_only=True) is None
def test_return_inline_metadata_1():
"""Test the return_inline_metadata method.
GIVEN a string with no inline metadata
WHEN the return_inline_metadata method is called
THEN return None
"""
assert P.return_inline_metadata("foo bar baz") is None
assert P.return_inline_metadata("foo:bar baz") is None
assert P.return_inline_metadata("foo:::bar baz") is None
assert P.return_inline_metadata("[foo:::bar] baz") is None
@pytest.mark.parametrize(
("string", "returned"),
[
("[k1:: v1]", [("k1", " v1", Wrapping.BRACKETS)]),
("(k/1:: v/1)", [("k/1", " v/1", Wrapping.PARENS)]),
(
"[k1::v1] and (k2:: v2)",
[("k1", "v1", Wrapping.BRACKETS), ("k2", " v2", Wrapping.PARENS)],
),
("(début::début)", [("début", "début", Wrapping.PARENS)]),
("[😉::🚀]", [("😉", "🚀", Wrapping.BRACKETS)]),
(
"(🛸rocket🚀ship:: a 🎅 [console] game)",
[("🛸rocket🚀ship", " a 🎅 [console] game", Wrapping.PARENS)],
),
],
)
def test_return_inline_metadata_2(string, returned):
"""Test the return_inline_metadata method.
GIVEN a string with inline metadata within a wrapping
WHEN the return_inline_metadata method is called
THEN return the wrapped inline metadata
"""
assert P.return_inline_metadata(string) == returned
@pytest.mark.parametrize(
("string", "returned"),
[
("k1::v1", [("k1", "v1", Wrapping.NONE)]),
("😉::🚀", [("😉", "🚀", Wrapping.NONE)]),
("k1:: w/ !@#$| ", [("k1", " w/ !@#$| ", Wrapping.NONE)]),
("クリスマス:: 家庭用ゲーム機", [("クリスマス", " 家庭用ゲ\u30fcム機", Wrapping.NONE)]),
("Noël:: Un jeu de console", [("Noël", " Un jeu de console", Wrapping.NONE)]),
("🎅:: a console game", [("🎅", " a console game", Wrapping.NONE)]),
("🛸rocket🚀ship:: a 🎅 console game", [("🛸rocket🚀ship", " a 🎅 console game", Wrapping.NONE)]),
(">flag::irish flag 🇮🇪", [("flag", "irish flag 🇮🇪", Wrapping.NONE)]),
("foo::[bar] baz", [("foo", "[bar] baz", Wrapping.NONE)]),
("foo::bar) baz", [("foo", "bar) baz", Wrapping.NONE)]),
("[foo::bar baz", [("foo", "bar baz", Wrapping.NONE)]),
("_foo_::bar baz", [("_foo_", "bar baz", Wrapping.NONE)]),
("**foo**::bar_baz", [("**foo**", "bar_baz", Wrapping.NONE)]),
("`foo`::`bar baz`", [("`foo`", "`bar baz`", Wrapping.NONE)]),
("`foo`:: `bar baz`", [("`foo`", " `bar baz`", Wrapping.NONE)]),
("`foo::bar baz`", [("`foo", "bar baz`", Wrapping.NONE)]),
("`foo:: bar baz`", [("`foo", " bar baz`", Wrapping.NONE)]),
("**URL**::`https://example.com`", [("**URL**", "`https://example.com`", Wrapping.NONE)]),
],
)
def test_return_inline_metadata_3(string, returned):
"""Test the return_inline_metadata method.
GIVEN a string with inline metadata without a wrapping
WHEN the return_inline_metadata method is called
THEN return the wrapped inline metadata
"""
assert P.return_inline_metadata(string) == returned
@pytest.mark.parametrize(
("string", "returned"),
[
("#foo", ["#foo"]),
("#tag1 #tag2 #tag3", ["#tag1", "#tag2", "#tag3"]),
("#foo.bar", ["#foo"]),
("#foo-bar_baz#", ["#foo-bar_baz"]),
("#daily/2021/20/08", ["#daily/2021/20/08"]),
("#🌱/🌿", ["#🌱/🌿"]),
("#début", ["#début"]),
("#/some/🚀/tag", ["#/some/🚀/tag"]),
(r"\\#foo", ["#foo"]),
("#f#oo", ["#f", "#oo"]),
("#foo#bar#baz", ["#foo", "#bar", "#baz"]),
],
)
def test_return_tags_1(string, returned):
"""Test the return_tags method.
GIVEN a string with tags
WHEN the return_tags method is called
THEN the valid tags are returned
"""
assert P.return_tags(string) == returned
@pytest.mark.parametrize(
("string"),
[
("##foo# ##bar # baz ##"),
("##foo"),
("foo##bar"),
("#1123"),
("foo bar"),
("aa#foo"),
("$#foo"),
],
)
def test_return_tags_2(string):
"""Test the return_tags method.
GIVEN a string without valid tags
WHEN the return_tags method is called
THEN None is returned
"""
assert P.return_tags(string) == []
def test_return_top_with_header_1():
"""Test the return_top_with_header method.
GIVEN a string with frontmatter above a first markdown header
WHEN return_top_with_header is called
THEN return the content up to the end of the first header
"""
content = """
---
key: value
---
# Hello World
foo bar baz
"""
assert P.return_top_with_header(content) == "---\nkey: value\n---\n# Hello World\n"
def test_return_top_with_header_2():
"""Test the return_top_with_header method.
GIVEN a string with content above a first markdown header on the first line
WHEN return_top_with_header is called
THEN return the content up to the end of the first header
"""
content = "\n\n### Hello World\nfoo bar\nfoo bar"
assert P.return_top_with_header(content) == "### Hello World\n"
def test_return_top_with_header_3():
"""Test the return_top_with_header method.
GIVEN a string with no markdown headers
WHEN return_top_with_header is called
THEN return None
"""
content = "Hello World\nfoo bar\nfoo bar"
assert not P.return_top_with_header(content)
def test_return_top_with_header_4():
"""Test the return_top_with_header method.
GIVEN a string with no markdown headers
WHEN return_top_with_header is called
THEN return None
"""
content = "qux bar baz\nbaz\nfoo\n### bar\n# baz foo bar"
assert P.return_top_with_header(content) == "qux bar baz\nbaz\nfoo\n### bar\n"
def test_strip_frontmatter_1():
"""Test the strip_frontmatter method.
GIVEN a string with frontmatter
WHEN the strip_frontmatter method is called
THEN the frontmatter is removed
"""
content = """
---
key: value
---
# Hello World
"""
assert P.strip_frontmatter(content).strip() == "# Hello World"
def test_strip_frontmatter_2():
"""Test the strip_frontmatter method.
GIVEN a string without frontmatter
WHEN the strip_frontmatter method is called
THEN nothing is removed
"""
content = """
# Hello World
---
key: value
---
"""
assert P.strip_frontmatter(content) == content
def test_strip_frontmatter_3():
"""Test the strip_frontmatter method.
GIVEN a string with frontmatter
WHEN the strip_frontmatter method is called with data_only=True
THEN the frontmatter is removed
"""
content = """
---
key: value
---
# Hello World
"""
assert P.strip_frontmatter(content, data_only=True).strip() == "---\n---\n# Hello World"
def test_strip_frontmatter_4():
"""Test the strip_frontmatter method.
GIVEN a string without frontmatter
WHEN the strip_frontmatter method is called with data_only=True
THEN nothing is removed
"""
content = """
# Hello World
---
key: value
---
"""
assert P.strip_frontmatter(content, data_only=True) == content
@pytest.mark.parametrize(
("content", "expected"),
[
("Foo `bar` baz `Qux` ```bar\n```", "Foo baz ```bar\n```"),
("foo", "foo"),
("foo `bar` baz `qux`", "foo baz "),
("key:: `value`", "key:: "),
("foo\nbar\n`baz`", "foo\nbar\n"),
("foo\nbar::baz\n`qux`", "foo\nbar::baz\n"),
("`foo::bar`", ""),
],
)
def test_strip_inline_code_1(content, expected):
"""Test the strip_inline_code method.
GIVEN a string with inline code
WHEN the strip_inline_code method is called
THEN the inline code is removed
"""
assert P.strip_inline_code(content) == expected
def test_validators():
"""Test validators."""
assert P.validate_tag_text.search("test_tag") is None
assert P.validate_tag_text.search("#asdf").group(0) == "#"

View File

@@ -1,188 +0,0 @@
# type: ignore
"""Tests for the regex module."""
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 [link](#no_tag) https://example.com/somepage.html_#no_url_tags"
INLINE_METADATA: str = """
**1:: 1**
2_2:: [[2_2]] | 2
asdfasdf [3:: 3] asdfasdf [7::7] asdf
[4:: 4] [5:: 5]
> 6:: 6
**8**:: **8**
10::
📅11:: 11/📅/11
emoji_📅_key:: 📅emoji_📅_key_value
"""
FRONTMATTER_CONTENT: str = """
---
tags:
- tag_1
- tag_2
-
- 📅/tag_3
frontmatter_Key1: "frontmatter_Key1_value"
frontmatter_Key2: ["note", "article"]
shared_key1: 'shared_key1_value'
---
more content
---
horizontal: rule
---
"""
CORRECT_FRONTMATTER_WITH_SEPARATORS: str = """---
tags:
- tag_1
- tag_2
-
- 📅/tag_3
frontmatter_Key1: "frontmatter_Key1_value"
frontmatter_Key2: ["note", "article"]
shared_key1: 'shared_key1_value'
---"""
CORRECT_FRONTMATTER_NO_SEPARATORS: str = """
tags:
- tag_1
- tag_2
-
- 📅/tag_3
frontmatter_Key1: "frontmatter_Key1_value"
frontmatter_Key2: ["note", "article"]
shared_key1: 'shared_key1_value'
"""
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",
"3",
"4",
"5",
"6",
"7_8",
"9/10",
"11-12",
"13",
"14",
"15",
"16",
"17",
"18",
"19",
"20",
"21",
"22",
"23",
"24",
"25",
"26",
"📅/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**"),
("", "", "2_2", "[[2_2]] | 2"),
("3", "3", "", ""),
("7", "7", "", ""),
("", "", "4", "4] [5:: 5]"),
("", "", "8**", "**8**"),
("", "", "11", "11/📅/11"),
("", "", "emoji_📅_key", "📅emoji_📅_key_value"),
]
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_strip_separators.search(FRONTMATTER_CONTENT).group(
"frontmatter"
)
assert found == CORRECT_FRONTMATTER_NO_SEPARATORS
with pytest.raises(AttributeError):
pattern.frontmatt_block_strip_separators.search(TAG_CONTENT).group("frontmatter")
def test_validators():
"""Test validators."""
pattern = Patterns()
assert pattern.validate_tag_text.search("test_tag") is None
assert pattern.validate_tag_text.search("#asdf").group(0) == "#"
assert pattern.validate_tag_text.search("#asdf").group(0) == "#"

View File

@@ -34,7 +34,7 @@ def test_validate_key_exists() -> None:
questions = Questions(vault=VAULT) questions = Questions(vault=VAULT)
assert "'test' does not exist" in questions._validate_key_exists("test") assert "'test' does not exist" in questions._validate_key_exists("test")
assert "Key cannot be empty" in questions._validate_key_exists("") assert "Key cannot be empty" in questions._validate_key_exists("")
assert questions._validate_key_exists("frontmatter_Key1") is True assert questions._validate_key_exists("frontmatter1") is True
def test_validate_new_key() -> None: def test_validate_new_key() -> None:
@@ -68,12 +68,12 @@ def test_validate_number() -> None:
assert questions._validate_number("1") is True assert questions._validate_number("1") is True
def test_validate_existing_inline_tag() -> None: def test_validate_existing_tag() -> None:
"""Test existing tag validation.""" """Test existing tag validation."""
questions = Questions(vault=VAULT) questions = Questions(vault=VAULT)
assert "Tag cannot be empty" in questions._validate_existing_inline_tag("") assert "Tag cannot be empty" in questions._validate_existing_tag("")
assert "'test' does not exist" in questions._validate_existing_inline_tag("test") assert "'test' does not exist" in questions._validate_existing_tag("test")
assert questions._validate_existing_inline_tag("shared_tag") is True assert questions._validate_existing_tag("shared_tag") is True
def test_validate_key_exists_regex() -> None: def test_validate_key_exists_regex() -> None:
@@ -82,7 +82,7 @@ def test_validate_key_exists_regex() -> None:
assert "'test' does not exist" in questions._validate_key_exists_regex("test") assert "'test' does not exist" in questions._validate_key_exists_regex("test")
assert "Key cannot be empty" in questions._validate_key_exists_regex("") assert "Key cannot be empty" in questions._validate_key_exists_regex("")
assert "Invalid regex" in questions._validate_key_exists_regex("[") assert "Invalid regex" in questions._validate_key_exists_regex("[")
assert questions._validate_key_exists_regex(r"\w+_Key\d") is True assert questions._validate_key_exists_regex(r"f\w+\d") is True
def test_validate_value() -> None: def test_validate_value() -> None:
@@ -90,29 +90,26 @@ def test_validate_value() -> None:
questions = Questions(vault=VAULT) questions = Questions(vault=VAULT)
assert questions._validate_value("test") is True assert questions._validate_value("test") is True
questions2 = Questions(vault=VAULT, key="frontmatter_Key1") questions2 = Questions(vault=VAULT, key="frontmatter1")
assert questions2._validate_value("test") == "frontmatter_Key1:test does not exist" assert questions2._validate_value("test") == "frontmatter1:test does not exist"
assert questions2._validate_value("author name") is True assert questions2._validate_value("foo") is True
def test_validate_value_exists_regex() -> None: def test_validate_value_exists_regex() -> None:
"""Test value exists regex validation.""" """Test value exists regex validation."""
questions2 = Questions(vault=VAULT, key="frontmatter_Key1") questions2 = Questions(vault=VAULT, key="frontmatter1")
assert "Invalid regex" in questions2._validate_value_exists_regex("[") assert "Invalid regex" in questions2._validate_value_exists_regex("[")
assert "Regex cannot be empty" in questions2._validate_value_exists_regex("") assert "Regex cannot be empty" in questions2._validate_value_exists_regex("")
assert ( assert (
questions2._validate_value_exists_regex(r"\d\d\d\w\d") questions2._validate_value_exists_regex(r"\d\d\d\w\d")
== r"No values in frontmatter_Key1 match regex: \d\d\d\w\d" == r"No values in frontmatter1 match regex: \d\d\d\w\d"
) )
assert questions2._validate_value_exists_regex(r"^author \w+") is True assert questions2._validate_value_exists_regex(r"^f\w{2}$") is True
def test_validate_new_value() -> None: def test_validate_new_value() -> None:
"""Test new value validation.""" """Test new value validation."""
questions = Questions(vault=VAULT, key="frontmatter_Key1") questions = Questions(vault=VAULT, key="frontmatter1")
assert questions._validate_new_value("not_exists") is True assert questions._validate_new_value("not_exists") is True
assert "Value cannot be empty" in questions._validate_new_value("") assert "Value cannot be empty" in questions._validate_new_value("")
assert ( assert questions._validate_new_value("foo") == "frontmatter1:foo already exists"
questions._validate_new_value("author name")
== "frontmatter_Key1:author name already exists"
)

View File

@@ -1,108 +1,535 @@
# type: ignore # type: ignore
"""Test the utilities module.""" """Test the utilities module."""
import pytest
import typer
from obsidian_metadata._utils import ( from obsidian_metadata._utils import (
clean_dictionary, clean_dictionary,
dict_contains, dict_contains,
dict_values_to_lists_strings, dict_keys_to_lower,
remove_markdown_sections, merge_dictionaries,
rename_in_dict,
validate_csv_bulk_imports,
) )
def test_dict_contains() -> None: def test_clean_dictionary_1():
"""Test dict_contains.""" """Test clean_dictionary() function.
d = {"key1": ["value1", "value2"], "key2": ["value3", "value4"], "key3": ["value5", "value6"]}
assert dict_contains(d, "key1") is True GIVEN a dictionary passed to clean_dictionary()
assert dict_contains(d, "key5") is False WHEN the dictionary is empty
assert dict_contains(d, "key1", "value1") is True THEN return an empty dictionary
assert dict_contains(d, "key1", "value5") is False
assert dict_contains(d, "key[1-2]", is_regex=True) is True
assert dict_contains(d, "^1", is_regex=True) is False
assert dict_contains(d, r"key\d", r"value\d", is_regex=True) is True
assert dict_contains(d, "key1$", "^alue", is_regex=True) is False
assert dict_contains(d, r"key\d", "value5", is_regex=True) is True
def test_dict_values_to_lists_strings():
"""Test converting dictionary values to lists of strings."""
dictionary = {
"key1": "value1",
"key2": ["value2", "value3", None],
"key3": {"key4": "value4"},
"key5": {"key6": {"key7": "value7"}},
"key6": None,
"key8": [1, 3, None, 4],
"key9": [None, "", "None"],
"key10": "None",
"key11": "",
}
result = dict_values_to_lists_strings(dictionary)
assert result == {
"key1": ["value1"],
"key10": ["None"],
"key11": [""],
"key2": ["None", "value2", "value3"],
"key3": {"key4": ["value4"]},
"key5": {"key6": {"key7": ["value7"]}},
"key6": ["None"],
"key8": ["1", "3", "4", "None"],
"key9": ["", "None", "None"],
}
result = dict_values_to_lists_strings(dictionary, strip_null_values=True)
assert result == {
"key1": ["value1"],
"key10": [],
"key11": [],
"key2": ["value2", "value3"],
"key3": {"key4": ["value4"]},
"key5": {"key6": {"key7": ["value7"]}},
"key6": [],
"key8": ["1", "3", "4"],
"key9": ["", "None"],
}
def test_remove_markdown_sections():
"""Test removing markdown sections."""
text: str = """
---
key: value
---
Lorem ipsum `dolor sit` amet.
```bash
echo "Hello World"
```
---
dd
---
""" """
result = remove_markdown_sections( assert clean_dictionary({}) == {}
text,
strip_codeblocks=True,
strip_frontmatter=True, def test_clean_dictionary_2():
strip_inlinecode=True, """Test clean_dictionary() function.
GIVEN a dictionary passed to clean_dictionary()
WHEN keys contain leading/trailing spaces
THEN remove the spaces from the keys
"""
assert clean_dictionary({" key 1 ": "value 1"}) == {"key 1": "value 1"}
def test_clean_dictionary_3():
"""Test clean_dictionary() function.
GIVEN a dictionary passed to clean_dictionary()
WHEN values contain leading/trailing spaces
THEN remove the spaces from the values
"""
assert clean_dictionary({"key 1": " value 1 "}) == {"key 1": "value 1"}
def test_clean_dictionary_4():
"""Test clean_dictionary() function.
GIVEN a dictionary passed to clean_dictionary()
WHEN keys or values contain leading/trailing asterisks
THEN remove the asterisks from the keys or values
"""
assert clean_dictionary({"**key_1**": ["**value 1**", "value 2"]}) == {
"key_1": ["value 1", "value 2"]
}
def test_clean_dictionary_5():
"""Test clean_dictionary() function.
GIVEN a dictionary passed to clean_dictionary()
WHEN keys or values contain leading/trailing brackets
THEN remove the brackets from the keys and values
"""
assert clean_dictionary({"[[key_1]]": ["[[value 1]]", "[value 2]"]}) == {
"key_1": ["value 1", "value 2"]
}
def test_clean_dictionary_6():
"""Test clean_dictionary() function.
GIVEN a dictionary passed to clean_dictionary()
WHEN keys or values contain leading/trailing hashtags
THEN remove the hashtags from the keys and values
"""
assert clean_dictionary({"#key_1": ["#value 1", "value 2#"]}) == {
"key_1": ["value 1", "value 2"]
}
def test_dict_contains_1():
"""Test dict_contains() function.
GIVEN calling dict_contains() with a dictionary
WHEN the dictionary is empty
THEN the function should return False
"""
assert dict_contains({}, "key1") is False
def test_dict_contains_2():
"""Test dict_contains() function.
GIVEN calling dict_contains() with a dictionary
WHEN when the key is not in the dictionary
THEN the function should return False
"""
assert dict_contains({"key1": "value1"}, "key2") is False
def test_dict_contains_3():
"""Test dict_contains() function.
GIVEN calling dict_contains() with a dictionary
WHEN when the key is in the dictionary
THEN the function should return True
"""
assert dict_contains({"key1": "value1"}, "key1") is True
def test_dict_contains_4():
"""Test dict_contains() function.
GIVEN calling dict_contains() with a dictionary
WHEN when the key and value are in the dictionary
THEN the function should return True
"""
assert dict_contains({"key1": "value1"}, "key1", "value1") is True
def test_dict_contains_5():
"""Test dict_contains() function.
GIVEN calling dict_contains() with a dictionary
WHEN when the key and value are not in the dictionary
THEN the function should return False
"""
assert dict_contains({"key1": "value1"}, "key1", "value2") is False
def test_dict_contains_6():
"""Test dict_contains() function.
GIVEN calling dict_contains() with a dictionary
WHEN a regex is used for the key and the key is in the dictionary
THEN the function should return True
"""
assert dict_contains({"key1": "value1"}, r"key\d", is_regex=True) is True
def test_dict_contains_7():
"""Test dict_contains() function.
GIVEN calling dict_contains() with a dictionary
WHEN a regex is used for the key and the key is not in the dictionary
THEN the function should return False
"""
assert dict_contains({"key1": "value1"}, r"key\d\d", is_regex=True) is False
def test_dict_contains_8():
"""Test dict_contains() function.
GIVEN calling dict_contains() with a dictionary
WHEN a regex is used for a value and the value is in the dictionary
THEN the function should return True
"""
assert dict_contains({"key1": "value1"}, "key1", r"\w+", is_regex=True) is True
def test_dict_contains_9():
"""Test dict_contains() function.
GIVEN calling dict_contains() with a dictionary
WHEN a regex is used for a value and the value is not in the dictionary
THEN the function should return False
"""
assert dict_contains({"key1": "value1"}, "key1", r"\d{2}", is_regex=True) is False
def test_dict_keys_to_lower() -> None:
"""Test the dict_keys_to_lower() function.
GIVEN a dictionary with mixed case keys
WHEN the dict_keys_to_lower() function is called
THEN the dictionary keys should be converted to lowercase
"""
test_dict = {"Key1": "Value1", "KEY2": "Value2", "key3": "Value3"}
assert dict_keys_to_lower(test_dict) == {"key1": "Value1", "key2": "Value2", "key3": "Value3"}
def test_merge_dictionaries_1():
"""Test merge_dictionaries() function.
GIVEN two dictionaries supplied to the merge_dictionaries() function
WHEN a value in dict1 is not a list
THEN raise a TypeError
"""
test_dict_1 = {"key1": "value1", "key2": "value2"}
test_dict_2 = {"key3": ["value3"], "key4": ["value4"]}
with pytest.raises(TypeError, match=r"key.*is not a list"):
merge_dictionaries(test_dict_1, test_dict_2)
def test_merge_dictionaries_2():
"""Test merge_dictionaries() function.
GIVEN two dictionaries supplied to the merge_dictionaries() function
WHEN a value in dict2 is not a list
THEN raise a TypeError
"""
test_dict_1 = {"key3": ["value3"], "key4": ["value4"]}
test_dict_2 = {"key1": "value1", "key2": "value2"}
with pytest.raises(TypeError, match=r"key.*is not a list"):
merge_dictionaries(test_dict_1, test_dict_2)
def test_merge_dictionaries_3():
"""Test merge_dictionaries() function.
GIVEN two dictionaries supplied to the merge_dictionaries() function
WHEN keys and values in both dictionaries are unique
THEN return a dictionary with the keys and values from both dictionaries
"""
test_dict_1 = {"key1": ["value1"], "key2": ["value2"]}
test_dict_2 = {"key3": ["value3"], "key4": ["value4"]}
assert merge_dictionaries(test_dict_1, test_dict_2) == {
"key1": ["value1"],
"key2": ["value2"],
"key3": ["value3"],
"key4": ["value4"],
}
def test_merge_dictionaries_4():
"""Test merge_dictionaries() function.
GIVEN two dictionaries supplied to the merge_dictionaries() function
WHEN keys in both dictionaries are not unique
THEN return a dictionary with the merged keys and values from both dictionaries
"""
test_dict_1 = {"key1": ["value1"], "key2": ["value2"]}
test_dict_2 = {"key1": ["value3"], "key2": ["value4"]}
assert merge_dictionaries(test_dict_1, test_dict_2) == {
"key1": ["value1", "value3"],
"key2": ["value2", "value4"],
}
def test_merge_dictionaries_5():
"""Test merge_dictionaries() function.
GIVEN two dictionaries supplied to the merge_dictionaries() function
WHEN keys and values both dictionaries are not unique
THEN return a dictionary with the merged keys and values from both dictionaries
"""
test_dict_1 = {"key1": ["a", "c"], "key2": ["a", "b"]}
test_dict_2 = {"key1": ["a", "b"], "key2": ["a", "c"]}
assert merge_dictionaries(test_dict_1, test_dict_2) == {
"key1": ["a", "b", "c"],
"key2": ["a", "b", "c"],
}
def test_merge_dictionaries_6():
"""Test merge_dictionaries() function.
GIVEN two dictionaries supplied to the merge_dictionaries() function
WHEN one of the dictionaries is empty
THEN return a dictionary the other dictionary
"""
test_dict_1 = {"key1": ["a", "c"], "key2": ["a", "b"]}
test_dict_2 = {}
assert merge_dictionaries(test_dict_1, test_dict_2) == {"key1": ["a", "c"], "key2": ["a", "b"]}
test_dict_1 = {}
test_dict_2 = {"key1": ["a", "c"], "key2": ["a", "b"]}
assert merge_dictionaries(test_dict_1, test_dict_2) == {"key1": ["a", "c"], "key2": ["a", "b"]}
def test_merge_dictionaries_7():
"""Test merge_dictionaries() function.
GIVEN two dictionaries supplied to the merge_dictionaries() function
WHEN keys and values both dictionaries are not unique
THEN ensure the original dictionaries objects are not modified
"""
test_dict_1 = {"key1": ["a", "c"], "key2": ["a", "b"]}
test_dict_2 = {"key1": ["a", "b"], "key2": ["a", "c"]}
assert merge_dictionaries(test_dict_1, test_dict_2) == {
"key1": ["a", "b", "c"],
"key2": ["a", "b", "c"],
}
assert test_dict_1 == {"key1": ["a", "c"], "key2": ["a", "b"]}
assert test_dict_2 == {"key1": ["a", "b"], "key2": ["a", "c"]}
def test_rename_in_dict_1():
"""Test rename_in_dict() function.
GIVEN a dictionary with values as a list
WHEN the rename_in_dict() function is called with a key that does not exist
THEN no keys should be renamed in the dictionary
"""
test_dict = {"key1": ["value1"], "key2": ["value2", "value3"]}
assert rename_in_dict(dictionary=test_dict, key="key4", value_1="key5") == test_dict
def test_rename_in_dict_2():
"""Test rename_in_dict() function.
GIVEN a dictionary with values as a list
WHEN the rename_in_dict() function is called with a key that exists and a new value for the key
THEN the key should be renamed in the returned dictionary and the original dictionary should not be modified
"""
test_dict = {"key1": ["value1"], "key2": ["value2", "value3"]}
assert rename_in_dict(dictionary=test_dict, key="key2", value_1="new_key") == {
"key1": ["value1"],
"new_key": ["value2", "value3"],
}
assert test_dict == {"key1": ["value1"], "key2": ["value2", "value3"]}
def test_rename_in_dict_3():
"""Test rename_in_dict() function.
GIVEN a dictionary with values as a list
WHEN the rename_in_dict() function is called with a key that exists value that does not exist
THEN the dictionary should not be modified
"""
test_dict = {"key1": ["value1"], "key2": ["value2", "value3"]}
assert (
rename_in_dict(dictionary=test_dict, key="key2", value_1="no_value", value_2="new_value")
== test_dict
) )
assert "```bash" not in result
assert "`dolor sit`" not in result
assert "---\nkey: value" not in result
assert "`" not in result
result = remove_markdown_sections(text)
assert "```bash" in result
assert "`dolor sit`" in result
assert "---\nkey: value" in result
assert "`" in result
def test_clean_dictionary(): def test_rename_in_dict_4():
"""Test cleaning a dictionary.""" """Test rename_in_dict() function.
dictionary = {" *key* ": ["**value**", "[[value2]]", "#value3"]}
new_dict = clean_dictionary(dictionary) GIVEN a dictionary with values as a list
assert new_dict == {"key": ["value", "value2", "value3"]} WHEN the rename_in_dict() function is called with a key that exists and a new value for a value
THEN update the specified value in the dictionary
"""
test_dict = {"key1": ["value1"], "key2": ["value2", "value3"]}
assert rename_in_dict(
dictionary=test_dict, key="key2", value_1="value2", value_2="new_value"
) == {"key1": ["value1"], "key2": ["new_value", "value3"]}
def test_rename_in_dict_5():
"""Test rename_in_dict() function.
GIVEN a dictionary with values as a list
WHEN the rename_in_dict() function is called with a key that exists and a an existing value for a renamed value
THEN only one instance of the new value should be in the key
"""
test_dict = {"key1": ["value1"], "key2": ["value2", "value3"]}
assert rename_in_dict(dictionary=test_dict, key="key2", value_1="value2", value_2="value3") == {
"key1": ["value1"],
"key2": ["value3"],
}
def test_validate_csv_bulk_imports_1(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file missing the `path` column
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
PATH,type,key,value
note1.md,frontmatter,key,value"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_2(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file missing the `type` column
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,Type,key,value
note1.md,frontmatter,key,value"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_3(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file missing the `key` column
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,value
note1.md,frontmatter,key,value"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_4(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file missing the `value` column
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,key,values
note1.md,frontmatter,key,value"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_5(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file with only headers
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = "path,type,key,value"
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_6(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a valid csv file
WHEN a path is given that does not exist in the vault
THEN show the user a warning
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,key,value
note1.md,frontmatter,key,value
note1.md,tag,key,value
note1.md,inline_metadata,key,value
note1.md,inline_metadata,key2,value
note1.md,inline_metadata,key2,value2
note2.md,frontmatter,key,value
note2.md,tag,key,value
note2.md,inline_metadata,key,value
note2.md,inline_metadata,key2,value
note2.md,inline_metadata,key2,value2
"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=["note1.md"])
def test_validate_csv_bulk_imports_7(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a valid csv file
WHEN if a type is not 'frontmatter' or 'inline_metadata', 'tag'
THEN exit the program
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,key,value
note1.md,frontmatter,key,value
note2.md,notvalid,key,value
"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=["note1.md", "note2.md"])
def test_validate_csv_bulk_imports_8(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a valid csv file
WHEN more than one row has the same path
THEN add the row to the list of rows for that path
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,key,value
note1.md,frontmatter,key,value
note1.md,tag,key,value
note1.md,inline_metadata,key,value
note1.md,inline_metadata,key2,value
note1.md,inline_metadata,key2,value2
note2.md,frontmatter,key,value
note2.md,tag,key,value
note2.md,inline_metadata,key,value
note2.md,inline_metadata,key2,value
note2.md,inline_metadata,key2,value2
"""
csv_path.write_text(csv_content)
csv_dict = validate_csv_bulk_imports(csv_path=csv_path, note_paths=["note1.md", "note2.md"])
assert csv_dict == {
"note1.md": [
{"key": "key", "type": "frontmatter", "value": "value"},
{"key": "key", "type": "tag", "value": "value"},
{"key": "key", "type": "inline_metadata", "value": "value"},
{"key": "key2", "type": "inline_metadata", "value": "value"},
{"key": "key2", "type": "inline_metadata", "value": "value2"},
],
"note2.md": [
{"key": "key", "type": "frontmatter", "value": "value"},
{"key": "key", "type": "tag", "value": "value"},
{"key": "key", "type": "inline_metadata", "value": "value"},
{"key": "key2", "type": "inline_metadata", "value": "value"},
{"key": "key2", "type": "inline_metadata", "value": "value2"},
],
}

File diff suppressed because it is too large Load Diff