notekey 0.1.0__py3-none-any.whl
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.
- notekey/__init__.py +0 -0
- notekey/main.py +329 -0
- notekey/markdown.py +88 -0
- notekey/template.py +28 -0
- notekey/utils.py +73 -0
- notekey-0.1.0.dist-info/METADATA +129 -0
- notekey-0.1.0.dist-info/RECORD +10 -0
- notekey-0.1.0.dist-info/WHEEL +5 -0
- notekey-0.1.0.dist-info/entry_points.txt +2 -0
- notekey-0.1.0.dist-info/top_level.txt +1 -0
notekey/__init__.py
ADDED
|
File without changes
|
notekey/main.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from notekey.markdown import Markdown
|
|
6
|
+
from notekey.template import BASE_FILTER_TEMPLATE, MOC_TEMPLATE
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _resolve_path(path: str | None = None) -> str:
|
|
10
|
+
"""Return the effective path: CLI arg > ``$OBSIDIAN_VAULT`` > ``.``."""
|
|
11
|
+
if path is not None:
|
|
12
|
+
return path
|
|
13
|
+
env = os.environ.get("OBSIDIAN_VAULT")
|
|
14
|
+
if env:
|
|
15
|
+
return env
|
|
16
|
+
return "."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_folder(path: str | None = None) -> Path:
|
|
20
|
+
target = Path(path).expanduser().resolve() if path else Path.cwd()
|
|
21
|
+
|
|
22
|
+
if not target.exists():
|
|
23
|
+
raise FileNotFoundError(f"Path does not exist: {target}")
|
|
24
|
+
if not target.is_dir():
|
|
25
|
+
raise NotADirectoryError(f"Path is not a directory: {target}")
|
|
26
|
+
|
|
27
|
+
return target
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _find_vault_root(target: Path) -> Path:
|
|
31
|
+
for candidate in [target, *target.parents]:
|
|
32
|
+
if (candidate / ".obsidian").is_dir():
|
|
33
|
+
return candidate
|
|
34
|
+
raise FileNotFoundError(f"No Obsidian vault (.obsidian) found above: {target}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_filters(name: str, tags: str | None = None) -> str:
|
|
38
|
+
parsed_tags = [tag.strip() for tag in tags.split(",")] if tags else []
|
|
39
|
+
|
|
40
|
+
filters: list[str] = []
|
|
41
|
+
for tag in [name, *parsed_tags]:
|
|
42
|
+
if tag and tag not in filters:
|
|
43
|
+
filters.append(tag)
|
|
44
|
+
|
|
45
|
+
return ", ".join(f'"{tag}"' for tag in filters)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _create_base(target: Path, name: str, tags: str | None = None, force: bool = False) -> Path:
|
|
49
|
+
vault_root = _find_vault_root(target)
|
|
50
|
+
folder = target.relative_to(vault_root).as_posix()
|
|
51
|
+
filters = _build_filters(name, tags)
|
|
52
|
+
base_path = target / f"{name}.base"
|
|
53
|
+
content = BASE_FILTER_TEMPLATE.format(folder=folder, name=name, filters=filters).lstrip()
|
|
54
|
+
|
|
55
|
+
if base_path.exists() and not force:
|
|
56
|
+
raise FileExistsError(f"Base file already exists: {base_path}")
|
|
57
|
+
|
|
58
|
+
base_path.write_text(content, encoding="utf-8")
|
|
59
|
+
return base_path
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _create_markdown(target: Path, name: str, force: bool = False) -> Path:
|
|
63
|
+
markdown_path = target / f"{name}.md"
|
|
64
|
+
content = MOC_TEMPLATE.format(name=name).lstrip()
|
|
65
|
+
|
|
66
|
+
if markdown_path.exists() and not force:
|
|
67
|
+
raise FileExistsError(f"Markdown file already exists: {markdown_path}")
|
|
68
|
+
|
|
69
|
+
markdown_path.write_text(content, encoding="utf-8")
|
|
70
|
+
return markdown_path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Search
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_filter(value: str) -> tuple[bool, str]:
|
|
79
|
+
"""Parse a filter value into (exact_match, clean_value).
|
|
80
|
+
|
|
81
|
+
``"=python"`` → ``(True, "python")`` — exact match
|
|
82
|
+
``"python"`` → ``(False, "python")`` — substring / containment match
|
|
83
|
+
``"= hello"`` → ``(True, " hello")`` — spaces after ``=`` are preserved
|
|
84
|
+
"""
|
|
85
|
+
if value.startswith("="):
|
|
86
|
+
return True, value[1:]
|
|
87
|
+
return False, value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _filename_candidates(md_path: Path, vault_root: Path) -> tuple[str, str, str]:
|
|
91
|
+
"""Return filename candidates: stem, vault-relative path, vault-relative stem."""
|
|
92
|
+
stem = md_path.stem
|
|
93
|
+
try:
|
|
94
|
+
rel = md_path.relative_to(vault_root)
|
|
95
|
+
return stem, rel.as_posix(), rel.with_suffix("").as_posix()
|
|
96
|
+
except ValueError:
|
|
97
|
+
return stem, md_path.name, stem
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _search_files(
|
|
101
|
+
vault_root: Path,
|
|
102
|
+
tags: list[str] | None = None,
|
|
103
|
+
filename: str | None = None,
|
|
104
|
+
content: str | None = None,
|
|
105
|
+
) -> list[Markdown]:
|
|
106
|
+
"""Walk *vault_root* for ``.md`` files and return those matching.
|
|
107
|
+
|
|
108
|
+
Filter values prefixed with ``=`` require an **exact** match;
|
|
109
|
+
unprefixed values use substring / containment matching.
|
|
110
|
+
|
|
111
|
+
Examples::
|
|
112
|
+
|
|
113
|
+
--tags py # tag contains "py" (matches "python")
|
|
114
|
+
--tags "=python" # tag equals "python"
|
|
115
|
+
--filename test # filename contains "test"
|
|
116
|
+
--filename "=test" # filename equals "test"
|
|
117
|
+
|
|
118
|
+
Filters are applied cheapest-first:
|
|
119
|
+
1. Filename (no parsing needed)
|
|
120
|
+
2. Content (raw text scan)
|
|
121
|
+
3. Tags (requires full ``Markdown`` object)
|
|
122
|
+
"""
|
|
123
|
+
# Parse exact/substring flags upfront.
|
|
124
|
+
filename_exact, filename_val = (
|
|
125
|
+
_parse_filter(filename) if filename else (False, None)
|
|
126
|
+
)
|
|
127
|
+
content_exact, content_val = (
|
|
128
|
+
_parse_filter(content) if content else (False, None)
|
|
129
|
+
)
|
|
130
|
+
parsed_tags: list[tuple[bool, str]] = (
|
|
131
|
+
[_parse_filter(t) for t in tags] if tags else []
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
results: list[Markdown] = []
|
|
135
|
+
|
|
136
|
+
for md_path in sorted(vault_root.rglob("*.md")):
|
|
137
|
+
# --- filename filter (cheapest) ---
|
|
138
|
+
if filename_val is not None:
|
|
139
|
+
candidates = _filename_candidates(md_path, vault_root)
|
|
140
|
+
|
|
141
|
+
if filename_exact:
|
|
142
|
+
if filename_val not in candidates:
|
|
143
|
+
continue
|
|
144
|
+
else:
|
|
145
|
+
val_lower = filename_val.lower()
|
|
146
|
+
if not any(val_lower in candidate.lower() for candidate in candidates):
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
# --- content filter (raw text scan, no full parse yet) ---
|
|
150
|
+
if content_val is not None:
|
|
151
|
+
try:
|
|
152
|
+
raw = md_path.read_text(encoding="utf-8", errors="replace")
|
|
153
|
+
except Exception:
|
|
154
|
+
continue
|
|
155
|
+
if content_exact:
|
|
156
|
+
if content_val != raw:
|
|
157
|
+
continue
|
|
158
|
+
else:
|
|
159
|
+
if content_val.lower() not in raw.lower():
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
# --- tags filter (requires full parse — most expensive) ---
|
|
163
|
+
try:
|
|
164
|
+
md = Markdown(md_path)
|
|
165
|
+
except Exception:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
if parsed_tags:
|
|
169
|
+
md_tag_lower = [t.lower() for t in md.tags]
|
|
170
|
+
matched = True
|
|
171
|
+
for exact, user_tag in parsed_tags:
|
|
172
|
+
if exact:
|
|
173
|
+
if not any(user_tag.lower() == ft.lower() for ft in md.tags):
|
|
174
|
+
matched = False
|
|
175
|
+
break
|
|
176
|
+
else:
|
|
177
|
+
if not any(user_tag.lower() in ft for ft in md_tag_lower):
|
|
178
|
+
matched = False
|
|
179
|
+
break
|
|
180
|
+
if not matched:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
results.append(md)
|
|
184
|
+
|
|
185
|
+
return results
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _display_search_results(results: list[Markdown], vault_root: Path) -> None:
|
|
189
|
+
"""Print search results with paths relative to the vault root."""
|
|
190
|
+
if not results:
|
|
191
|
+
print("No matching files found.")
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
print(f"\nFound {len(results)} file{'s' if len(results) != 1 else ''}:\n")
|
|
195
|
+
|
|
196
|
+
# Column widths
|
|
197
|
+
name_width = max(len(_relative_to_vault(r, vault_root)) for r in results)
|
|
198
|
+
name_width = max(name_width, 8) + 2 ##### ≥ "Filename"
|
|
199
|
+
|
|
200
|
+
for md in results:
|
|
201
|
+
rel = _relative_to_vault(md, vault_root)
|
|
202
|
+
tags_str = (
|
|
203
|
+
", ".join(md.tags[:5])
|
|
204
|
+
+ ("..." if len(md.tags) > 5 else "")
|
|
205
|
+
or "—"
|
|
206
|
+
)
|
|
207
|
+
print(
|
|
208
|
+
f" {rel:<{name_width}} "
|
|
209
|
+
f"tags: [{tags_str}] "
|
|
210
|
+
f"{md.reading_time} "
|
|
211
|
+
f"{md._normalize_size()}"
|
|
212
|
+
)
|
|
213
|
+
print()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _relative_to_vault(md: Markdown, vault_root: Path) -> str:
|
|
217
|
+
"""Return the file path relative to the vault root."""
|
|
218
|
+
try:
|
|
219
|
+
return md._path.relative_to(vault_root).as_posix()
|
|
220
|
+
except ValueError:
|
|
221
|
+
return md._path.name
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _display_file(md: Markdown, vault_root: Path) -> None:
|
|
225
|
+
"""Print the full raw content of a ``Markdown`` file."""
|
|
226
|
+
print(md.content, end="")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
230
|
+
parser = argparse.ArgumentParser(prog="notekey")
|
|
231
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
232
|
+
|
|
233
|
+
# -- init ---------------------------------------------------------------
|
|
234
|
+
init_parser = subparsers.add_parser("init", help="Initialize notekey in a folder")
|
|
235
|
+
init_parser.add_argument(
|
|
236
|
+
"path",
|
|
237
|
+
nargs="?",
|
|
238
|
+
default=None,
|
|
239
|
+
help="Target folder (defaults to $OBSIDIAN_VAULT or current directory)",
|
|
240
|
+
)
|
|
241
|
+
init_parser.add_argument(
|
|
242
|
+
"-t",
|
|
243
|
+
"--tags",
|
|
244
|
+
help="Comma-separated tags to append to the default {name} filter in {name}.base",
|
|
245
|
+
)
|
|
246
|
+
init_parser.add_argument(
|
|
247
|
+
"--force",
|
|
248
|
+
action="store_true",
|
|
249
|
+
help="Overwrite existing {name}.base and {name}.md files",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# -- search -------------------------------------------------------------
|
|
253
|
+
search_parser = subparsers.add_parser("search", help="Search markdown files in the vault")
|
|
254
|
+
search_parser.add_argument(
|
|
255
|
+
"path",
|
|
256
|
+
nargs="?",
|
|
257
|
+
default=None,
|
|
258
|
+
help="Directory to search in (defaults to $OBSIDIAN_VAULT or current directory)",
|
|
259
|
+
)
|
|
260
|
+
search_parser.add_argument(
|
|
261
|
+
"-t",
|
|
262
|
+
"--tags",
|
|
263
|
+
help="Comma-separated list of tags — file must have ALL of them",
|
|
264
|
+
)
|
|
265
|
+
search_parser.add_argument(
|
|
266
|
+
"-f",
|
|
267
|
+
"--filename",
|
|
268
|
+
help="Substring to match in the filename (case-insensitive)",
|
|
269
|
+
)
|
|
270
|
+
search_parser.add_argument(
|
|
271
|
+
"-c",
|
|
272
|
+
"--content",
|
|
273
|
+
help="Substring to match in the file content (case-insensitive)",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# -- read ---------------------------------------------------------------
|
|
277
|
+
read_parser = subparsers.add_parser("read", help="Read a single markdown file by name or path")
|
|
278
|
+
read_parser.add_argument(
|
|
279
|
+
"filename",
|
|
280
|
+
help="Filename or path to match — first match wins (prefix = for exact)",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return parser
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def main() -> None:
|
|
287
|
+
parser = build_parser()
|
|
288
|
+
args = parser.parse_args()
|
|
289
|
+
|
|
290
|
+
if args.command == "init":
|
|
291
|
+
target = _get_folder(_resolve_path(args.path))
|
|
292
|
+
current_location = str(target)
|
|
293
|
+
folder_name = target.name
|
|
294
|
+
base_path = _create_base(target, folder_name, tags=args.tags, force=args.force)
|
|
295
|
+
markdown_path = _create_markdown(target, folder_name, force=args.force)
|
|
296
|
+
|
|
297
|
+
print(f"Full path: {current_location}")
|
|
298
|
+
print(f"Folder name: {folder_name}")
|
|
299
|
+
print(f"Created base: {base_path}")
|
|
300
|
+
print(f"Created markdown: {markdown_path}")
|
|
301
|
+
|
|
302
|
+
elif args.command == "search":
|
|
303
|
+
target = _get_folder(_resolve_path(args.path))
|
|
304
|
+
vault_root = _find_vault_root(target)
|
|
305
|
+
tags = [t.strip() for t in args.tags.split(",")] if args.tags else None
|
|
306
|
+
results = _search_files(
|
|
307
|
+
vault_root,
|
|
308
|
+
tags=tags,
|
|
309
|
+
filename=args.filename,
|
|
310
|
+
content=args.content,
|
|
311
|
+
)
|
|
312
|
+
_display_search_results(results, vault_root)
|
|
313
|
+
|
|
314
|
+
elif args.command == "read":
|
|
315
|
+
target = _get_folder(_resolve_path())
|
|
316
|
+
vault_root = _find_vault_root(target)
|
|
317
|
+
results = _search_files(vault_root, filename=args.filename)
|
|
318
|
+
|
|
319
|
+
if not results:
|
|
320
|
+
print(f"No file found matching: {args.filename}")
|
|
321
|
+
elif len(results) > 1:
|
|
322
|
+
print(f"Multiple matches — showing first of {len(results)}:")
|
|
323
|
+
_display_file(results[0], vault_root)
|
|
324
|
+
else:
|
|
325
|
+
_display_file(results[0], vault_root)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
if __name__ == "__main__":
|
|
329
|
+
main()
|
notekey/markdown.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import frontmatter
|
|
6
|
+
import readtime
|
|
7
|
+
|
|
8
|
+
from notekey.utils import extract_inline_tags, extract_markdown_links, extract_wiki_links, normalize_size
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Markdown:
|
|
12
|
+
"""Represents a parsed Markdown file from an Obsidian vault.
|
|
13
|
+
|
|
14
|
+
Uses ``python-frontmatter`` for frontmatter parsing and ``readtime``
|
|
15
|
+
for reading-time estimation.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
created_at: datetime.datetime
|
|
19
|
+
updated_at: datetime.datetime
|
|
20
|
+
name: str
|
|
21
|
+
size: float
|
|
22
|
+
metadata: dict
|
|
23
|
+
content: str
|
|
24
|
+
tags: list[str]
|
|
25
|
+
links: list[str]
|
|
26
|
+
reading_time: str
|
|
27
|
+
|
|
28
|
+
def __init__(self, path: str | Path) -> None:
|
|
29
|
+
self._path = Path(path).expanduser().resolve()
|
|
30
|
+
|
|
31
|
+
if not self._path.exists():
|
|
32
|
+
raise FileNotFoundError(f"File not found: {self._path}")
|
|
33
|
+
if not self._path.is_file():
|
|
34
|
+
raise IsADirectoryError(f"Path is not a file: {self._path}")
|
|
35
|
+
|
|
36
|
+
stat = self._path.stat()
|
|
37
|
+
self.name = self._path.stem
|
|
38
|
+
self._size_bytes: float = float(stat.st_size)
|
|
39
|
+
self.size = self._size_bytes
|
|
40
|
+
self.created_at = datetime.datetime.fromtimestamp(stat.st_birthtime)
|
|
41
|
+
self.updated_at = datetime.datetime.fromtimestamp(stat.st_mtime)
|
|
42
|
+
|
|
43
|
+
# Full raw content (frontmatter + body) — needed for link extraction.
|
|
44
|
+
self.content = self._path.read_text(encoding="utf-8")
|
|
45
|
+
|
|
46
|
+
# Parse frontmatter via python-frontmatter.
|
|
47
|
+
post: Any = frontmatter.loads(self.content)
|
|
48
|
+
self.metadata = dict(post.metadata)
|
|
49
|
+
self._body: str = post.content # body *without* frontmatter
|
|
50
|
+
|
|
51
|
+
# Reading time estimated from the body content.
|
|
52
|
+
self._reading_time_result = readtime.of_markdown(self._body)
|
|
53
|
+
self.reading_time = str(self._reading_time_result)
|
|
54
|
+
|
|
55
|
+
self.tags = self._get_tags()
|
|
56
|
+
self.links = self._get_links()
|
|
57
|
+
|
|
58
|
+
def _get_tags(self) -> list[str]:
|
|
59
|
+
"""Extract tags from frontmatter *and* inline ``#tag`` references."""
|
|
60
|
+
tags: set[str] = set()
|
|
61
|
+
|
|
62
|
+
# Tags from frontmatter
|
|
63
|
+
fm_tags = self.metadata.get("tags", [])
|
|
64
|
+
if isinstance(fm_tags, list):
|
|
65
|
+
tags.update(str(t).strip() for t in fm_tags if t)
|
|
66
|
+
elif isinstance(fm_tags, str):
|
|
67
|
+
tags.add(fm_tags.strip())
|
|
68
|
+
|
|
69
|
+
# Tags from body content (#tag syntax)
|
|
70
|
+
tags.update(extract_inline_tags(self._body))
|
|
71
|
+
|
|
72
|
+
return sorted(tags)
|
|
73
|
+
|
|
74
|
+
def _get_links(self) -> list[str]:
|
|
75
|
+
"""Extract wiki links and markdown links from the file content."""
|
|
76
|
+
links: set[str] = set()
|
|
77
|
+
links.update(extract_wiki_links(self.content))
|
|
78
|
+
links.update(extract_markdown_links(self.content))
|
|
79
|
+
return sorted(links)
|
|
80
|
+
|
|
81
|
+
def _normalize_size(self) -> str:
|
|
82
|
+
"""Return the file size as a human-readable string."""
|
|
83
|
+
return normalize_size(self._size_bytes)
|
|
84
|
+
|
|
85
|
+
def __repr__(self) -> str:
|
|
86
|
+
return (
|
|
87
|
+
f"<Markdown filename='{self.name}' size='{self._normalize_size()}'>"
|
|
88
|
+
)
|
notekey/template.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BASE_FILTER_TEMPLATE="""
|
|
2
|
+
views:
|
|
3
|
+
- type: table
|
|
4
|
+
name: Notes
|
|
5
|
+
filters:
|
|
6
|
+
or:
|
|
7
|
+
- and:
|
|
8
|
+
- '!file.inFolder("{folder}")'
|
|
9
|
+
- file.tags.containsAny({filters})
|
|
10
|
+
- '!file.inFolder("{folder}/openspec")'
|
|
11
|
+
- and:
|
|
12
|
+
- file.inFolder("{folder}")
|
|
13
|
+
- '!file.name.endsWith(".base")'
|
|
14
|
+
- file.name != "{name}"
|
|
15
|
+
- '!file.inFolder("{folder}/openspec")'
|
|
16
|
+
order:
|
|
17
|
+
- date
|
|
18
|
+
- file.name
|
|
19
|
+
- about
|
|
20
|
+
sort:
|
|
21
|
+
- property: date
|
|
22
|
+
direction: ASC
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
MOC_TEMPLATE="""
|
|
26
|
+
### Notes
|
|
27
|
+
![[{name}.base]]
|
|
28
|
+
"""
|
notekey/utils.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Utility functions for markdown parsing and formatting."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def extract_inline_tags(text: str) -> set[str]:
|
|
7
|
+
"""Extract inline ``#tag`` references from *text*.
|
|
8
|
+
|
|
9
|
+
Matches ``#tag``, ``#tag/subtag``, etc. while avoiding false
|
|
10
|
+
positives inside fenced code blocks, inline code spans,
|
|
11
|
+
markdown links, or hex colours.
|
|
12
|
+
"""
|
|
13
|
+
# Strip fenced code blocks first so tags inside them are ignored.
|
|
14
|
+
body = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
|
|
15
|
+
# Strip inline code spans as well.
|
|
16
|
+
body = re.sub(r"`[^`]+`", "", body)
|
|
17
|
+
|
|
18
|
+
tags: set[str] = set()
|
|
19
|
+
|
|
20
|
+
# Match #identifier (with optional /path). The tag body cannot
|
|
21
|
+
# contain a trailing period so that ``#tag.`` correctly yields ``tag``.
|
|
22
|
+
pattern = re.compile(
|
|
23
|
+
r"(?:^|(?<=\s))"
|
|
24
|
+
r"#([a-zA-Z_][a-zA-Z0-9_/-]*)"
|
|
25
|
+
r"(?=\s|$|[.,;:!?)])"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
for match in pattern.finditer(body):
|
|
29
|
+
tags.add(match.group(1))
|
|
30
|
+
|
|
31
|
+
return tags
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extract_wiki_links(text: str) -> set[str]:
|
|
35
|
+
"""Extract Obsidian wiki-link targets from *text*.
|
|
36
|
+
|
|
37
|
+
Handles ``[[target]]`` and ``[[target|display text]]``.
|
|
38
|
+
"""
|
|
39
|
+
links: set[str] = set()
|
|
40
|
+
pattern = re.compile(r"\[\[([^\[\]]+?)(?:\|[^\[\]]*)?\]\]")
|
|
41
|
+
for match in pattern.finditer(text):
|
|
42
|
+
target = match.group(1).strip()
|
|
43
|
+
# Skip empty / anchor-only links
|
|
44
|
+
if target:
|
|
45
|
+
links.add(target)
|
|
46
|
+
return links
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def extract_markdown_links(text: str) -> set[str]:
|
|
50
|
+
"""Extract markdown-style links from *text*.
|
|
51
|
+
|
|
52
|
+
Handles ``[text](url)``, excluding reference-style links.
|
|
53
|
+
"""
|
|
54
|
+
links: set[str] = set()
|
|
55
|
+
pattern = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
|
56
|
+
for match in pattern.finditer(text):
|
|
57
|
+
url = match.group(2).strip()
|
|
58
|
+
if url:
|
|
59
|
+
links.add(url)
|
|
60
|
+
return links
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize_size(size_bytes: int | float) -> str:
|
|
64
|
+
"""Convert a byte count to a human-readable string."""
|
|
65
|
+
size = float(size_bytes)
|
|
66
|
+
if size < 1024:
|
|
67
|
+
return f"{size:.0f} B"
|
|
68
|
+
elif size < 1024**2:
|
|
69
|
+
return f"{size / 1024:.1f} KB"
|
|
70
|
+
elif size < 1024**3:
|
|
71
|
+
return f"{size / 1024**2:.1f} MB"
|
|
72
|
+
else:
|
|
73
|
+
return f"{size / 1024**3:.2f} GB"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: notekey
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI utilities for Obsidian vault notes: initialize note bases, search markdown files, and read note content.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: python-frontmatter>=1.1.0
|
|
8
|
+
Requires-Dist: readtime>=3.0.0
|
|
9
|
+
|
|
10
|
+
# notekey
|
|
11
|
+
|
|
12
|
+
CLI utilities for working with Markdown notes in an Obsidian vault.
|
|
13
|
+
|
|
14
|
+
`notekey` can:
|
|
15
|
+
|
|
16
|
+
- initialize a folder with an Obsidian `.base` file and a matching Markdown note
|
|
17
|
+
- search vault notes by tag, filename, or content
|
|
18
|
+
- read a single note by filename or path match
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- Python 3.11+
|
|
23
|
+
- An Obsidian vault directory containing a `.obsidian/` folder
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
After the package is published to PyPI:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install notekey
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
notekey init [path]
|
|
37
|
+
notekey search [path] [--tags TAGS] [--filename NAME] [--content TEXT]
|
|
38
|
+
notekey read FILENAME
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If `path` is omitted, `notekey` uses the `OBSIDIAN_VAULT` environment variable. If that variable is not set, it uses the current directory.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export OBSIDIAN_VAULT="/path/to/your/vault"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Initialize a note folder
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
notekey init /path/to/vault/Projects/MyProject
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This creates:
|
|
54
|
+
|
|
55
|
+
- `MyProject.base`
|
|
56
|
+
- `MyProject.md`
|
|
57
|
+
|
|
58
|
+
Add extra base filters with comma-separated tags:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
notekey init /path/to/vault/Projects/MyProject --tags python,docs
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Overwrite existing generated files with:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
notekey init /path/to/vault/Projects/MyProject --force
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Search notes
|
|
71
|
+
|
|
72
|
+
Search by tag:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
notekey search --tags python
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Search by multiple tags. Files must match all tags:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
notekey search --tags python,web
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Search by filename:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
notekey search --filename flask
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Search by content:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
notekey search --content pandas
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Use `=` for exact matches:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
notekey search --tags "=python"
|
|
100
|
+
notekey search --filename "=deep/hidden-note"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Read a note
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
notekey read flask-app
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
For an exact filename match:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
notekey read "=flask-app"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
Run tests with:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pytest
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Build distribution files with:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
python -m build
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Upload is intentionally manual. Use TestPyPI or PyPI with your API token when ready.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
notekey/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
notekey/main.py,sha256=NSlGwf4E0KsPOtMxHpfexJY5DIF8jN8TrmAydXBDvyA,10939
|
|
3
|
+
notekey/markdown.py,sha256=Y_9B9sbT5Cb2hnuj4jf4nc0mL8QhB6KpgFGGPSEDXZg,2975
|
|
4
|
+
notekey/template.py,sha256=xoZWw_ol5NPr4myjCT1bqpNxZ64Lti570iikUwakfHU,597
|
|
5
|
+
notekey/utils.py,sha256=-gv-2i1dlyNjIFVx0JFxsu32Va1sSJvrjS7hd8nzKEw,2167
|
|
6
|
+
notekey-0.1.0.dist-info/METADATA,sha256=UjLEAHYzOEAhz2-ys_lHfWH75Hl0ClQuxSHp4ttBfwU,2230
|
|
7
|
+
notekey-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
notekey-0.1.0.dist-info/entry_points.txt,sha256=dm1zLQ7b55XWB4XJJKG82akoymNG5BOLIfETHaZ3sVg,46
|
|
9
|
+
notekey-0.1.0.dist-info/top_level.txt,sha256=A7CIGTSxqWb_DqiUD68FKKv1M2yWoKz0iEN3B9LGzUs,8
|
|
10
|
+
notekey-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
notekey
|