odcli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- odcli-0.1.0/PKG-INFO +185 -0
- odcli-0.1.0/README.md +165 -0
- odcli-0.1.0/pyproject.toml +48 -0
- odcli-0.1.0/setup.cfg +4 -0
- odcli-0.1.0/src/obsidian_cli/__init__.py +4 -0
- odcli-0.1.0/src/obsidian_cli/__main__.py +6 -0
- odcli-0.1.0/src/obsidian_cli/cli.py +187 -0
- odcli-0.1.0/src/obsidian_cli/vault.py +127 -0
- odcli-0.1.0/src/odcli.egg-info/PKG-INFO +185 -0
- odcli-0.1.0/src/odcli.egg-info/SOURCES.txt +12 -0
- odcli-0.1.0/src/odcli.egg-info/dependency_links.txt +1 -0
- odcli-0.1.0/src/odcli.egg-info/entry_points.txt +2 -0
- odcli-0.1.0/src/odcli.egg-info/top_level.txt +1 -0
- odcli-0.1.0/tests/test_cli.py +98 -0
odcli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: odcli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small CLI for reading and writing notes in a local Obsidian vault.
|
|
5
|
+
Author: Chang LeHung
|
|
6
|
+
Project-URL: Homepage, https://github.com/Chang-LeHung/obsidian-cli
|
|
7
|
+
Project-URL: Repository, https://github.com/Chang-LeHung/obsidian-cli
|
|
8
|
+
Project-URL: Issues, https://github.com/Chang-LeHung/obsidian-cli/issues
|
|
9
|
+
Keywords: obsidian,cli,markdown,notes,vault
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Classifier: Environment :: Console
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# odcli
|
|
22
|
+
|
|
23
|
+
一个基于 Python 的本地 CLI,用来对 Obsidian vault 做读写操作。它直接操作 vault 里的 Markdown 文件,不依赖 Obsidian 的私有接口,因此稳定、可移植,也方便后续扩展。
|
|
24
|
+
|
|
25
|
+
## 功能
|
|
26
|
+
|
|
27
|
+
- 校验 vault 路径是否有效
|
|
28
|
+
- 列出 vault 中的 Markdown 笔记
|
|
29
|
+
- 读取指定笔记
|
|
30
|
+
- 按指定行区间读取笔记
|
|
31
|
+
- 覆盖写入或自动创建笔记
|
|
32
|
+
- 按指定行区间覆盖写入
|
|
33
|
+
- 追加内容到笔记末尾
|
|
34
|
+
- 在 vault 中全文搜索
|
|
35
|
+
|
|
36
|
+
## 使用 uv
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
40
|
+
uv sync
|
|
41
|
+
uv run obsidian-cli --help
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
运行测试:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
48
|
+
uv run python -m unittest discover -s tests
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
构建分发包:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
55
|
+
uv build
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
发布到 PyPI 后,安装包名会是 `odcli`。
|
|
59
|
+
|
|
60
|
+
## 直接运行
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
64
|
+
./obsidian-cli --help
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
如果你更喜欢显式调用 Python:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
PYTHONPATH=src python3 -m obsidian_cli --help
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 可选安装
|
|
74
|
+
|
|
75
|
+
如果你想安装到虚拟环境中:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
79
|
+
uv sync
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 指定 vault
|
|
83
|
+
|
|
84
|
+
优先级如下:
|
|
85
|
+
|
|
86
|
+
1. `--vault /path/to/vault`
|
|
87
|
+
2. 环境变量 `OBSIDIAN_VAULT`
|
|
88
|
+
|
|
89
|
+
示例:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
|
|
93
|
+
./obsidian-cli check
|
|
94
|
+
./obsidian-cli list
|
|
95
|
+
./obsidian-cli read Inbox/today.md
|
|
96
|
+
./obsidian-cli read-lines Inbox/today.md 3 8
|
|
97
|
+
./obsidian-cli write Inbox/today.md --content "# Today"
|
|
98
|
+
./obsidian-cli write-lines Inbox/today.md 3 4 --content "- replaced\n- lines\n"
|
|
99
|
+
./obsidian-cli append Inbox/today.md --content "\n- new item"
|
|
100
|
+
./obsidian-cli search "project alpha"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## 命令
|
|
104
|
+
|
|
105
|
+
### `check`
|
|
106
|
+
|
|
107
|
+
检查 vault 是否存在,以及是否包含 `.obsidian` 目录。
|
|
108
|
+
|
|
109
|
+
### `list`
|
|
110
|
+
|
|
111
|
+
列出 vault 中的 Markdown 文件。
|
|
112
|
+
|
|
113
|
+
可选参数:
|
|
114
|
+
|
|
115
|
+
- `--limit N`
|
|
116
|
+
|
|
117
|
+
### `read`
|
|
118
|
+
|
|
119
|
+
读取笔记内容。
|
|
120
|
+
|
|
121
|
+
参数:
|
|
122
|
+
|
|
123
|
+
- `note_path`:相对于 vault 根目录的路径
|
|
124
|
+
|
|
125
|
+
### `write`
|
|
126
|
+
|
|
127
|
+
覆盖写入笔记;若父目录不存在会自动创建。
|
|
128
|
+
|
|
129
|
+
参数:
|
|
130
|
+
|
|
131
|
+
- `note_path`
|
|
132
|
+
- `--content TEXT`
|
|
133
|
+
- `--stdin`:从标准输入读取内容
|
|
134
|
+
|
|
135
|
+
可选参数:
|
|
136
|
+
|
|
137
|
+
- `--create-only`:若文件已存在则报错
|
|
138
|
+
|
|
139
|
+
### `read-lines`
|
|
140
|
+
|
|
141
|
+
读取指定行区间,行号从 `1` 开始,区间是闭区间。
|
|
142
|
+
|
|
143
|
+
参数:
|
|
144
|
+
|
|
145
|
+
- `note_path`
|
|
146
|
+
- `start_line`
|
|
147
|
+
- `end_line`
|
|
148
|
+
|
|
149
|
+
### `write-lines`
|
|
150
|
+
|
|
151
|
+
用新内容替换指定行区间,行号从 `1` 开始,区间是闭区间。
|
|
152
|
+
|
|
153
|
+
参数:
|
|
154
|
+
|
|
155
|
+
- `note_path`
|
|
156
|
+
- `start_line`
|
|
157
|
+
- `end_line`
|
|
158
|
+
- `--content TEXT`
|
|
159
|
+
- `--stdin`
|
|
160
|
+
|
|
161
|
+
### `append`
|
|
162
|
+
|
|
163
|
+
追加内容到笔记末尾。
|
|
164
|
+
|
|
165
|
+
参数:
|
|
166
|
+
|
|
167
|
+
- `note_path`
|
|
168
|
+
- `--content TEXT`
|
|
169
|
+
- `--stdin`
|
|
170
|
+
|
|
171
|
+
### `search`
|
|
172
|
+
|
|
173
|
+
在所有 Markdown 笔记中搜索文本。
|
|
174
|
+
|
|
175
|
+
参数:
|
|
176
|
+
|
|
177
|
+
- `query`
|
|
178
|
+
- `--case-sensitive`
|
|
179
|
+
|
|
180
|
+
## 测试
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
184
|
+
uv run python -m unittest discover -s tests
|
|
185
|
+
```
|
odcli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# odcli
|
|
2
|
+
|
|
3
|
+
一个基于 Python 的本地 CLI,用来对 Obsidian vault 做读写操作。它直接操作 vault 里的 Markdown 文件,不依赖 Obsidian 的私有接口,因此稳定、可移植,也方便后续扩展。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- 校验 vault 路径是否有效
|
|
8
|
+
- 列出 vault 中的 Markdown 笔记
|
|
9
|
+
- 读取指定笔记
|
|
10
|
+
- 按指定行区间读取笔记
|
|
11
|
+
- 覆盖写入或自动创建笔记
|
|
12
|
+
- 按指定行区间覆盖写入
|
|
13
|
+
- 追加内容到笔记末尾
|
|
14
|
+
- 在 vault 中全文搜索
|
|
15
|
+
|
|
16
|
+
## 使用 uv
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
20
|
+
uv sync
|
|
21
|
+
uv run obsidian-cli --help
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
运行测试:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
28
|
+
uv run python -m unittest discover -s tests
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
构建分发包:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
35
|
+
uv build
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
发布到 PyPI 后,安装包名会是 `odcli`。
|
|
39
|
+
|
|
40
|
+
## 直接运行
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
44
|
+
./obsidian-cli --help
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
如果你更喜欢显式调用 Python:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
PYTHONPATH=src python3 -m obsidian_cli --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 可选安装
|
|
54
|
+
|
|
55
|
+
如果你想安装到虚拟环境中:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
59
|
+
uv sync
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 指定 vault
|
|
63
|
+
|
|
64
|
+
优先级如下:
|
|
65
|
+
|
|
66
|
+
1. `--vault /path/to/vault`
|
|
67
|
+
2. 环境变量 `OBSIDIAN_VAULT`
|
|
68
|
+
|
|
69
|
+
示例:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
|
|
73
|
+
./obsidian-cli check
|
|
74
|
+
./obsidian-cli list
|
|
75
|
+
./obsidian-cli read Inbox/today.md
|
|
76
|
+
./obsidian-cli read-lines Inbox/today.md 3 8
|
|
77
|
+
./obsidian-cli write Inbox/today.md --content "# Today"
|
|
78
|
+
./obsidian-cli write-lines Inbox/today.md 3 4 --content "- replaced\n- lines\n"
|
|
79
|
+
./obsidian-cli append Inbox/today.md --content "\n- new item"
|
|
80
|
+
./obsidian-cli search "project alpha"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## 命令
|
|
84
|
+
|
|
85
|
+
### `check`
|
|
86
|
+
|
|
87
|
+
检查 vault 是否存在,以及是否包含 `.obsidian` 目录。
|
|
88
|
+
|
|
89
|
+
### `list`
|
|
90
|
+
|
|
91
|
+
列出 vault 中的 Markdown 文件。
|
|
92
|
+
|
|
93
|
+
可选参数:
|
|
94
|
+
|
|
95
|
+
- `--limit N`
|
|
96
|
+
|
|
97
|
+
### `read`
|
|
98
|
+
|
|
99
|
+
读取笔记内容。
|
|
100
|
+
|
|
101
|
+
参数:
|
|
102
|
+
|
|
103
|
+
- `note_path`:相对于 vault 根目录的路径
|
|
104
|
+
|
|
105
|
+
### `write`
|
|
106
|
+
|
|
107
|
+
覆盖写入笔记;若父目录不存在会自动创建。
|
|
108
|
+
|
|
109
|
+
参数:
|
|
110
|
+
|
|
111
|
+
- `note_path`
|
|
112
|
+
- `--content TEXT`
|
|
113
|
+
- `--stdin`:从标准输入读取内容
|
|
114
|
+
|
|
115
|
+
可选参数:
|
|
116
|
+
|
|
117
|
+
- `--create-only`:若文件已存在则报错
|
|
118
|
+
|
|
119
|
+
### `read-lines`
|
|
120
|
+
|
|
121
|
+
读取指定行区间,行号从 `1` 开始,区间是闭区间。
|
|
122
|
+
|
|
123
|
+
参数:
|
|
124
|
+
|
|
125
|
+
- `note_path`
|
|
126
|
+
- `start_line`
|
|
127
|
+
- `end_line`
|
|
128
|
+
|
|
129
|
+
### `write-lines`
|
|
130
|
+
|
|
131
|
+
用新内容替换指定行区间,行号从 `1` 开始,区间是闭区间。
|
|
132
|
+
|
|
133
|
+
参数:
|
|
134
|
+
|
|
135
|
+
- `note_path`
|
|
136
|
+
- `start_line`
|
|
137
|
+
- `end_line`
|
|
138
|
+
- `--content TEXT`
|
|
139
|
+
- `--stdin`
|
|
140
|
+
|
|
141
|
+
### `append`
|
|
142
|
+
|
|
143
|
+
追加内容到笔记末尾。
|
|
144
|
+
|
|
145
|
+
参数:
|
|
146
|
+
|
|
147
|
+
- `note_path`
|
|
148
|
+
- `--content TEXT`
|
|
149
|
+
- `--stdin`
|
|
150
|
+
|
|
151
|
+
### `search`
|
|
152
|
+
|
|
153
|
+
在所有 Markdown 笔记中搜索文本。
|
|
154
|
+
|
|
155
|
+
参数:
|
|
156
|
+
|
|
157
|
+
- `query`
|
|
158
|
+
- `--case-sensitive`
|
|
159
|
+
|
|
160
|
+
## 测试
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
164
|
+
uv run python -m unittest discover -s tests
|
|
165
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "odcli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A small CLI for reading and writing notes in a local Obsidian vault."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Chang LeHung" }
|
|
13
|
+
]
|
|
14
|
+
dependencies = []
|
|
15
|
+
keywords = ["obsidian", "cli", "markdown", "notes", "vault"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: MacOS",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Utilities",
|
|
24
|
+
"Environment :: Console",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/Chang-LeHung/obsidian-cli"
|
|
29
|
+
Repository = "https://github.com/Chang-LeHung/obsidian-cli"
|
|
30
|
+
Issues = "https://github.com/Chang-LeHung/obsidian-cli/issues"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
obsidian-cli = "obsidian_cli.cli:main"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"build>=1.2.2",
|
|
38
|
+
"twine>=6.1.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools]
|
|
42
|
+
package-dir = {"" = "src"}
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
|
46
|
+
|
|
47
|
+
[tool.uv]
|
|
48
|
+
package = true
|
odcli-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from obsidian_cli.vault import ObsidianVault, VaultError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="obsidian-cli",
|
|
14
|
+
description="Read and write notes inside a local Obsidian vault.",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--vault",
|
|
18
|
+
help="Path to the Obsidian vault. Falls back to OBSIDIAN_VAULT.",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
22
|
+
|
|
23
|
+
subparsers.add_parser("check", help="Validate the vault path.")
|
|
24
|
+
|
|
25
|
+
list_parser = subparsers.add_parser("list", help="List markdown notes.")
|
|
26
|
+
list_parser.add_argument("--limit", type=int, default=0, help="Maximum number of notes to show.")
|
|
27
|
+
|
|
28
|
+
read_parser = subparsers.add_parser("read", help="Read a note.")
|
|
29
|
+
read_parser.add_argument("note_path", help="Path to the note relative to the vault root.")
|
|
30
|
+
|
|
31
|
+
read_lines_parser = subparsers.add_parser("read-lines", help="Read a line range from a note.")
|
|
32
|
+
read_lines_parser.add_argument("note_path", help="Path to the note relative to the vault root.")
|
|
33
|
+
read_lines_parser.add_argument("start_line", type=int, help="1-based start line, inclusive.")
|
|
34
|
+
read_lines_parser.add_argument("end_line", type=int, help="1-based end line, inclusive.")
|
|
35
|
+
|
|
36
|
+
write_parser = subparsers.add_parser("write", help="Write a note.")
|
|
37
|
+
write_parser.add_argument("note_path", help="Path to the note relative to the vault root.")
|
|
38
|
+
write_parser.add_argument("--content", help="Text content to write.")
|
|
39
|
+
write_parser.add_argument("--stdin", action="store_true", help="Read note content from stdin.")
|
|
40
|
+
write_parser.add_argument(
|
|
41
|
+
"--create-only",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Fail if the note already exists.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
append_parser = subparsers.add_parser("append", help="Append to a note.")
|
|
47
|
+
append_parser.add_argument("note_path", help="Path to the note relative to the vault root.")
|
|
48
|
+
append_parser.add_argument("--content", help="Text content to append.")
|
|
49
|
+
append_parser.add_argument("--stdin", action="store_true", help="Read appended content from stdin.")
|
|
50
|
+
|
|
51
|
+
write_lines_parser = subparsers.add_parser(
|
|
52
|
+
"write-lines",
|
|
53
|
+
help="Replace a line range inside a note.",
|
|
54
|
+
)
|
|
55
|
+
write_lines_parser.add_argument("note_path", help="Path to the note relative to the vault root.")
|
|
56
|
+
write_lines_parser.add_argument("start_line", type=int, help="1-based start line, inclusive.")
|
|
57
|
+
write_lines_parser.add_argument("end_line", type=int, help="1-based end line, inclusive.")
|
|
58
|
+
write_lines_parser.add_argument("--content", help="Replacement text for the selected lines.")
|
|
59
|
+
write_lines_parser.add_argument("--stdin", action="store_true", help="Read replacement text from stdin.")
|
|
60
|
+
|
|
61
|
+
search_parser = subparsers.add_parser("search", help="Search text in notes.")
|
|
62
|
+
search_parser.add_argument("query", help="Text to search for.")
|
|
63
|
+
search_parser.add_argument(
|
|
64
|
+
"--case-sensitive",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="Use case-sensitive matching.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_vault_path(cli_value: str | None) -> Path:
|
|
73
|
+
raw_value = cli_value or os.environ.get("OBSIDIAN_VAULT")
|
|
74
|
+
if not raw_value:
|
|
75
|
+
raise VaultError("vault path is required; use --vault or OBSIDIAN_VAULT")
|
|
76
|
+
return Path(raw_value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_content_arg(content: str | None, use_stdin: bool) -> str:
|
|
80
|
+
if content is not None and use_stdin:
|
|
81
|
+
raise VaultError("use either --content or --stdin, not both")
|
|
82
|
+
if content is not None:
|
|
83
|
+
return content
|
|
84
|
+
if use_stdin:
|
|
85
|
+
return sys.stdin.read()
|
|
86
|
+
raise VaultError("content is required; provide --content or --stdin")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_check(vault: ObsidianVault) -> int:
|
|
90
|
+
if not vault.exists():
|
|
91
|
+
raise VaultError(f"vault does not exist: {vault.root}")
|
|
92
|
+
print(f"vault: {vault.root}")
|
|
93
|
+
if vault.is_obsidian_vault():
|
|
94
|
+
print("status: ok (.obsidian found)")
|
|
95
|
+
else:
|
|
96
|
+
print("status: warning (.obsidian not found)")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_list(vault: ObsidianVault, limit: int) -> int:
|
|
101
|
+
notes = vault.list_notes()
|
|
102
|
+
if limit > 0:
|
|
103
|
+
notes = notes[:limit]
|
|
104
|
+
for note in notes:
|
|
105
|
+
print(note)
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_read(vault: ObsidianVault, note_path: str) -> int:
|
|
110
|
+
print(vault.read_note(note_path), end="")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_read_lines(vault: ObsidianVault, note_path: str, start_line: int, end_line: int) -> int:
|
|
115
|
+
print(vault.read_note_lines(note_path, start_line, end_line), end="")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_write(vault: ObsidianVault, note_path: str, content: str, create_only: bool) -> int:
|
|
120
|
+
path = vault.write_note(note_path, content, create_only=create_only)
|
|
121
|
+
print(path)
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_append(vault: ObsidianVault, note_path: str, content: str) -> int:
|
|
126
|
+
path = vault.append_note(note_path, content)
|
|
127
|
+
print(path)
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_write_lines(
|
|
132
|
+
vault: ObsidianVault,
|
|
133
|
+
note_path: str,
|
|
134
|
+
start_line: int,
|
|
135
|
+
end_line: int,
|
|
136
|
+
content: str,
|
|
137
|
+
) -> int:
|
|
138
|
+
path = vault.write_note_lines(note_path, start_line, end_line, content)
|
|
139
|
+
print(path)
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_search(vault: ObsidianVault, query: str, case_sensitive: bool) -> int:
|
|
144
|
+
matches = vault.search(query, case_sensitive=case_sensitive)
|
|
145
|
+
for match in matches:
|
|
146
|
+
print(f"{match.note_path}:{match.line_number}: {match.line_text}")
|
|
147
|
+
return 0 if matches else 1
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main(argv: list[str] | None = None) -> int:
|
|
151
|
+
parser = build_parser()
|
|
152
|
+
args = parser.parse_args(argv)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
vault = ObsidianVault(resolve_vault_path(args.vault))
|
|
156
|
+
|
|
157
|
+
if args.command == "check":
|
|
158
|
+
return cmd_check(vault)
|
|
159
|
+
if args.command == "list":
|
|
160
|
+
return cmd_list(vault, args.limit)
|
|
161
|
+
if args.command == "read":
|
|
162
|
+
return cmd_read(vault, args.note_path)
|
|
163
|
+
if args.command == "read-lines":
|
|
164
|
+
return cmd_read_lines(vault, args.note_path, args.start_line, args.end_line)
|
|
165
|
+
if args.command == "write":
|
|
166
|
+
content = read_content_arg(args.content, args.stdin)
|
|
167
|
+
return cmd_write(vault, args.note_path, content, args.create_only)
|
|
168
|
+
if args.command == "append":
|
|
169
|
+
content = read_content_arg(args.content, args.stdin)
|
|
170
|
+
return cmd_append(vault, args.note_path, content)
|
|
171
|
+
if args.command == "write-lines":
|
|
172
|
+
content = read_content_arg(args.content, args.stdin)
|
|
173
|
+
return cmd_write_lines(
|
|
174
|
+
vault,
|
|
175
|
+
args.note_path,
|
|
176
|
+
args.start_line,
|
|
177
|
+
args.end_line,
|
|
178
|
+
content,
|
|
179
|
+
)
|
|
180
|
+
if args.command == "search":
|
|
181
|
+
return cmd_search(vault, args.query, args.case_sensitive)
|
|
182
|
+
except VaultError as exc:
|
|
183
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
184
|
+
return 2
|
|
185
|
+
|
|
186
|
+
parser.error(f"unsupported command: {args.command}")
|
|
187
|
+
return 2
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class VaultError(Exception):
|
|
8
|
+
"""Raised when the vault cannot fulfill a requested operation."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class SearchMatch:
|
|
13
|
+
note_path: str
|
|
14
|
+
line_number: int
|
|
15
|
+
line_text: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ObsidianVault:
|
|
19
|
+
def __init__(self, root: Path) -> None:
|
|
20
|
+
self.root = root.expanduser().resolve()
|
|
21
|
+
|
|
22
|
+
def exists(self) -> bool:
|
|
23
|
+
return self.root.exists() and self.root.is_dir()
|
|
24
|
+
|
|
25
|
+
def is_obsidian_vault(self) -> bool:
|
|
26
|
+
return (self.root / ".obsidian").exists()
|
|
27
|
+
|
|
28
|
+
def resolve_note(self, note_path: str) -> Path:
|
|
29
|
+
candidate = (self.root / note_path).resolve()
|
|
30
|
+
if candidate != self.root and self.root not in candidate.parents:
|
|
31
|
+
raise VaultError(f"path escapes vault root: {note_path}")
|
|
32
|
+
return candidate
|
|
33
|
+
|
|
34
|
+
def list_notes(self) -> list[str]:
|
|
35
|
+
if not self.exists():
|
|
36
|
+
raise VaultError(f"vault does not exist: {self.root}")
|
|
37
|
+
return sorted(
|
|
38
|
+
str(path.relative_to(self.root))
|
|
39
|
+
for path in self.root.rglob("*.md")
|
|
40
|
+
if ".obsidian" not in path.parts
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def read_note(self, note_path: str) -> str:
|
|
44
|
+
path = self.resolve_note(note_path)
|
|
45
|
+
if not path.exists():
|
|
46
|
+
raise VaultError(f"note not found: {note_path}")
|
|
47
|
+
if not path.is_file():
|
|
48
|
+
raise VaultError(f"not a file: {note_path}")
|
|
49
|
+
return path.read_text(encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
def _read_existing_markdown_path(self, note_path: str) -> Path:
|
|
52
|
+
path = self.resolve_note(note_path)
|
|
53
|
+
if path.suffix.lower() != ".md":
|
|
54
|
+
raise VaultError("only markdown files are supported (.md)")
|
|
55
|
+
if not path.exists():
|
|
56
|
+
raise VaultError(f"note not found: {note_path}")
|
|
57
|
+
if not path.is_file():
|
|
58
|
+
raise VaultError(f"not a file: {note_path}")
|
|
59
|
+
return path
|
|
60
|
+
|
|
61
|
+
def _validate_line_range(self, start_line: int, end_line: int, line_count: int) -> None:
|
|
62
|
+
if start_line < 1 or end_line < 1:
|
|
63
|
+
raise VaultError("line numbers must be >= 1")
|
|
64
|
+
if start_line > end_line:
|
|
65
|
+
raise VaultError("start line must be <= end line")
|
|
66
|
+
if end_line > line_count:
|
|
67
|
+
raise VaultError(
|
|
68
|
+
f"line range {start_line}-{end_line} exceeds file length {line_count}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def write_note(self, note_path: str, content: str, create_only: bool = False) -> Path:
|
|
72
|
+
path = self.resolve_note(note_path)
|
|
73
|
+
if path.suffix.lower() != ".md":
|
|
74
|
+
raise VaultError("only markdown files are supported (.md)")
|
|
75
|
+
if create_only and path.exists():
|
|
76
|
+
raise VaultError(f"note already exists: {note_path}")
|
|
77
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
path.write_text(content, encoding="utf-8")
|
|
79
|
+
return path
|
|
80
|
+
|
|
81
|
+
def append_note(self, note_path: str, content: str) -> Path:
|
|
82
|
+
path = self.resolve_note(note_path)
|
|
83
|
+
if path.suffix.lower() != ".md":
|
|
84
|
+
raise VaultError("only markdown files are supported (.md)")
|
|
85
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
87
|
+
handle.write(content)
|
|
88
|
+
return path
|
|
89
|
+
|
|
90
|
+
def read_note_lines(self, note_path: str, start_line: int, end_line: int) -> str:
|
|
91
|
+
path = self._read_existing_markdown_path(note_path)
|
|
92
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
93
|
+
self._validate_line_range(start_line, end_line, len(lines))
|
|
94
|
+
return "".join(lines[start_line - 1 : end_line])
|
|
95
|
+
|
|
96
|
+
def write_note_lines(
|
|
97
|
+
self, note_path: str, start_line: int, end_line: int, content: str
|
|
98
|
+
) -> Path:
|
|
99
|
+
path = self._read_existing_markdown_path(note_path)
|
|
100
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
101
|
+
self._validate_line_range(start_line, end_line, len(lines))
|
|
102
|
+
|
|
103
|
+
replacement_lines = content.splitlines(keepends=True)
|
|
104
|
+
lines[start_line - 1 : end_line] = replacement_lines
|
|
105
|
+
path.write_text("".join(lines), encoding="utf-8")
|
|
106
|
+
return path
|
|
107
|
+
|
|
108
|
+
def search(self, query: str, case_sensitive: bool = False) -> list[SearchMatch]:
|
|
109
|
+
if not query:
|
|
110
|
+
raise VaultError("query must not be empty")
|
|
111
|
+
|
|
112
|
+
needle = query if case_sensitive else query.lower()
|
|
113
|
+
matches: list[SearchMatch] = []
|
|
114
|
+
|
|
115
|
+
for note_path in self.list_notes():
|
|
116
|
+
content = self.read_note(note_path)
|
|
117
|
+
for idx, line in enumerate(content.splitlines(), start=1):
|
|
118
|
+
haystack = line if case_sensitive else line.lower()
|
|
119
|
+
if needle in haystack:
|
|
120
|
+
matches.append(
|
|
121
|
+
SearchMatch(
|
|
122
|
+
note_path=note_path,
|
|
123
|
+
line_number=idx,
|
|
124
|
+
line_text=line,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return matches
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: odcli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small CLI for reading and writing notes in a local Obsidian vault.
|
|
5
|
+
Author: Chang LeHung
|
|
6
|
+
Project-URL: Homepage, https://github.com/Chang-LeHung/obsidian-cli
|
|
7
|
+
Project-URL: Repository, https://github.com/Chang-LeHung/obsidian-cli
|
|
8
|
+
Project-URL: Issues, https://github.com/Chang-LeHung/obsidian-cli/issues
|
|
9
|
+
Keywords: obsidian,cli,markdown,notes,vault
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Classifier: Environment :: Console
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# odcli
|
|
22
|
+
|
|
23
|
+
一个基于 Python 的本地 CLI,用来对 Obsidian vault 做读写操作。它直接操作 vault 里的 Markdown 文件,不依赖 Obsidian 的私有接口,因此稳定、可移植,也方便后续扩展。
|
|
24
|
+
|
|
25
|
+
## 功能
|
|
26
|
+
|
|
27
|
+
- 校验 vault 路径是否有效
|
|
28
|
+
- 列出 vault 中的 Markdown 笔记
|
|
29
|
+
- 读取指定笔记
|
|
30
|
+
- 按指定行区间读取笔记
|
|
31
|
+
- 覆盖写入或自动创建笔记
|
|
32
|
+
- 按指定行区间覆盖写入
|
|
33
|
+
- 追加内容到笔记末尾
|
|
34
|
+
- 在 vault 中全文搜索
|
|
35
|
+
|
|
36
|
+
## 使用 uv
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
40
|
+
uv sync
|
|
41
|
+
uv run obsidian-cli --help
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
运行测试:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
48
|
+
uv run python -m unittest discover -s tests
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
构建分发包:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
55
|
+
uv build
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
发布到 PyPI 后,安装包名会是 `odcli`。
|
|
59
|
+
|
|
60
|
+
## 直接运行
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
64
|
+
./obsidian-cli --help
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
如果你更喜欢显式调用 Python:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
PYTHONPATH=src python3 -m obsidian_cli --help
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 可选安装
|
|
74
|
+
|
|
75
|
+
如果你想安装到虚拟环境中:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
79
|
+
uv sync
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 指定 vault
|
|
83
|
+
|
|
84
|
+
优先级如下:
|
|
85
|
+
|
|
86
|
+
1. `--vault /path/to/vault`
|
|
87
|
+
2. 环境变量 `OBSIDIAN_VAULT`
|
|
88
|
+
|
|
89
|
+
示例:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
|
|
93
|
+
./obsidian-cli check
|
|
94
|
+
./obsidian-cli list
|
|
95
|
+
./obsidian-cli read Inbox/today.md
|
|
96
|
+
./obsidian-cli read-lines Inbox/today.md 3 8
|
|
97
|
+
./obsidian-cli write Inbox/today.md --content "# Today"
|
|
98
|
+
./obsidian-cli write-lines Inbox/today.md 3 4 --content "- replaced\n- lines\n"
|
|
99
|
+
./obsidian-cli append Inbox/today.md --content "\n- new item"
|
|
100
|
+
./obsidian-cli search "project alpha"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## 命令
|
|
104
|
+
|
|
105
|
+
### `check`
|
|
106
|
+
|
|
107
|
+
检查 vault 是否存在,以及是否包含 `.obsidian` 目录。
|
|
108
|
+
|
|
109
|
+
### `list`
|
|
110
|
+
|
|
111
|
+
列出 vault 中的 Markdown 文件。
|
|
112
|
+
|
|
113
|
+
可选参数:
|
|
114
|
+
|
|
115
|
+
- `--limit N`
|
|
116
|
+
|
|
117
|
+
### `read`
|
|
118
|
+
|
|
119
|
+
读取笔记内容。
|
|
120
|
+
|
|
121
|
+
参数:
|
|
122
|
+
|
|
123
|
+
- `note_path`:相对于 vault 根目录的路径
|
|
124
|
+
|
|
125
|
+
### `write`
|
|
126
|
+
|
|
127
|
+
覆盖写入笔记;若父目录不存在会自动创建。
|
|
128
|
+
|
|
129
|
+
参数:
|
|
130
|
+
|
|
131
|
+
- `note_path`
|
|
132
|
+
- `--content TEXT`
|
|
133
|
+
- `--stdin`:从标准输入读取内容
|
|
134
|
+
|
|
135
|
+
可选参数:
|
|
136
|
+
|
|
137
|
+
- `--create-only`:若文件已存在则报错
|
|
138
|
+
|
|
139
|
+
### `read-lines`
|
|
140
|
+
|
|
141
|
+
读取指定行区间,行号从 `1` 开始,区间是闭区间。
|
|
142
|
+
|
|
143
|
+
参数:
|
|
144
|
+
|
|
145
|
+
- `note_path`
|
|
146
|
+
- `start_line`
|
|
147
|
+
- `end_line`
|
|
148
|
+
|
|
149
|
+
### `write-lines`
|
|
150
|
+
|
|
151
|
+
用新内容替换指定行区间,行号从 `1` 开始,区间是闭区间。
|
|
152
|
+
|
|
153
|
+
参数:
|
|
154
|
+
|
|
155
|
+
- `note_path`
|
|
156
|
+
- `start_line`
|
|
157
|
+
- `end_line`
|
|
158
|
+
- `--content TEXT`
|
|
159
|
+
- `--stdin`
|
|
160
|
+
|
|
161
|
+
### `append`
|
|
162
|
+
|
|
163
|
+
追加内容到笔记末尾。
|
|
164
|
+
|
|
165
|
+
参数:
|
|
166
|
+
|
|
167
|
+
- `note_path`
|
|
168
|
+
- `--content TEXT`
|
|
169
|
+
- `--stdin`
|
|
170
|
+
|
|
171
|
+
### `search`
|
|
172
|
+
|
|
173
|
+
在所有 Markdown 笔记中搜索文本。
|
|
174
|
+
|
|
175
|
+
参数:
|
|
176
|
+
|
|
177
|
+
- `query`
|
|
178
|
+
- `--case-sensitive`
|
|
179
|
+
|
|
180
|
+
## 测试
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
cd /Users/huchang/agents/obsidian_cli
|
|
184
|
+
uv run python -m unittest discover -s tests
|
|
185
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/obsidian_cli/__init__.py
|
|
4
|
+
src/obsidian_cli/__main__.py
|
|
5
|
+
src/obsidian_cli/cli.py
|
|
6
|
+
src/obsidian_cli/vault.py
|
|
7
|
+
src/odcli.egg-info/PKG-INFO
|
|
8
|
+
src/odcli.egg-info/SOURCES.txt
|
|
9
|
+
src/odcli.egg-info/dependency_links.txt
|
|
10
|
+
src/odcli.egg-info/entry_points.txt
|
|
11
|
+
src/odcli.egg-info/top_level.txt
|
|
12
|
+
tests/test_cli.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
obsidian_cli
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
8
|
+
from io import StringIO
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
11
|
+
|
|
12
|
+
from obsidian_cli.cli import main
|
|
13
|
+
from obsidian_cli.vault import ObsidianVault, VaultError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VaultTests(unittest.TestCase):
|
|
17
|
+
def setUp(self) -> None:
|
|
18
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
19
|
+
self.vault_root = Path(self.temp_dir.name) / "vault"
|
|
20
|
+
self.vault_root.mkdir()
|
|
21
|
+
(self.vault_root / ".obsidian").mkdir()
|
|
22
|
+
self.vault = ObsidianVault(self.vault_root)
|
|
23
|
+
|
|
24
|
+
def tearDown(self) -> None:
|
|
25
|
+
self.temp_dir.cleanup()
|
|
26
|
+
|
|
27
|
+
def test_write_and_read_note(self) -> None:
|
|
28
|
+
self.vault.write_note("Inbox/test.md", "# Hello")
|
|
29
|
+
self.assertEqual(self.vault.read_note("Inbox/test.md"), "# Hello")
|
|
30
|
+
|
|
31
|
+
def test_list_notes_excludes_obsidian_config(self) -> None:
|
|
32
|
+
self.vault.write_note("Note.md", "body")
|
|
33
|
+
(self.vault_root / ".obsidian" / "workspace.md").write_text(
|
|
34
|
+
"ignored", encoding="utf-8"
|
|
35
|
+
)
|
|
36
|
+
self.assertEqual(self.vault.list_notes(), ["Note.md"])
|
|
37
|
+
|
|
38
|
+
def test_search_matches_line(self) -> None:
|
|
39
|
+
self.vault.write_note("Note.md", "alpha\nbeta project\nomega")
|
|
40
|
+
matches = self.vault.search("project")
|
|
41
|
+
self.assertEqual(len(matches), 1)
|
|
42
|
+
self.assertEqual(matches[0].line_number, 2)
|
|
43
|
+
|
|
44
|
+
def test_read_note_lines(self) -> None:
|
|
45
|
+
self.vault.write_note("Note.md", "a\nb\nc\nd\n")
|
|
46
|
+
self.assertEqual(self.vault.read_note_lines("Note.md", 2, 3), "b\nc\n")
|
|
47
|
+
|
|
48
|
+
def test_write_note_lines_replaces_only_selected_range(self) -> None:
|
|
49
|
+
self.vault.write_note("Note.md", "a\nb\nc\nd\n")
|
|
50
|
+
self.vault.write_note_lines("Note.md", 2, 3, "x\ny\n")
|
|
51
|
+
self.assertEqual(self.vault.read_note("Note.md"), "a\nx\ny\nd\n")
|
|
52
|
+
|
|
53
|
+
def test_write_note_lines_rejects_invalid_range(self) -> None:
|
|
54
|
+
self.vault.write_note("Note.md", "a\nb\n")
|
|
55
|
+
with self.assertRaises(VaultError):
|
|
56
|
+
self.vault.write_note_lines("Note.md", 2, 4, "x\n")
|
|
57
|
+
|
|
58
|
+
def test_cli_check(self) -> None:
|
|
59
|
+
with redirect_stdout(StringIO()), redirect_stderr(StringIO()):
|
|
60
|
+
exit_code = main(["--vault", str(self.vault_root), "check"])
|
|
61
|
+
self.assertEqual(exit_code, 0)
|
|
62
|
+
|
|
63
|
+
def test_cli_write_requires_content(self) -> None:
|
|
64
|
+
with redirect_stdout(StringIO()), redirect_stderr(StringIO()):
|
|
65
|
+
exit_code = main(["--vault", str(self.vault_root), "write", "x.md"])
|
|
66
|
+
self.assertEqual(exit_code, 2)
|
|
67
|
+
|
|
68
|
+
def test_cli_read_lines(self) -> None:
|
|
69
|
+
self.vault.write_note("Note.md", "a\nb\nc\n")
|
|
70
|
+
stdout = StringIO()
|
|
71
|
+
with redirect_stdout(stdout), redirect_stderr(StringIO()):
|
|
72
|
+
exit_code = main(
|
|
73
|
+
["--vault", str(self.vault_root), "read-lines", "Note.md", "2", "3"]
|
|
74
|
+
)
|
|
75
|
+
self.assertEqual(exit_code, 0)
|
|
76
|
+
self.assertEqual(stdout.getvalue(), "b\nc\n")
|
|
77
|
+
|
|
78
|
+
def test_cli_write_lines(self) -> None:
|
|
79
|
+
self.vault.write_note("Note.md", "a\nb\nc\n")
|
|
80
|
+
with redirect_stdout(StringIO()), redirect_stderr(StringIO()):
|
|
81
|
+
exit_code = main(
|
|
82
|
+
[
|
|
83
|
+
"--vault",
|
|
84
|
+
str(self.vault_root),
|
|
85
|
+
"write-lines",
|
|
86
|
+
"Note.md",
|
|
87
|
+
"2",
|
|
88
|
+
"2",
|
|
89
|
+
"--content",
|
|
90
|
+
"beta\n",
|
|
91
|
+
]
|
|
92
|
+
)
|
|
93
|
+
self.assertEqual(exit_code, 0)
|
|
94
|
+
self.assertEqual(self.vault.read_note("Note.md"), "a\nbeta\nc\n")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
unittest.main()
|