notekey 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.
notekey-0.1.0/PKG-INFO ADDED
@@ -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,120 @@
1
+ # notekey
2
+
3
+ CLI utilities for working with Markdown notes in an Obsidian vault.
4
+
5
+ `notekey` can:
6
+
7
+ - initialize a folder with an Obsidian `.base` file and a matching Markdown note
8
+ - search vault notes by tag, filename, or content
9
+ - read a single note by filename or path match
10
+
11
+ ## Requirements
12
+
13
+ - Python 3.11+
14
+ - An Obsidian vault directory containing a `.obsidian/` folder
15
+
16
+ ## Installation
17
+
18
+ After the package is published to PyPI:
19
+
20
+ ```bash
21
+ pip install notekey
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ notekey init [path]
28
+ notekey search [path] [--tags TAGS] [--filename NAME] [--content TEXT]
29
+ notekey read FILENAME
30
+ ```
31
+
32
+ If `path` is omitted, `notekey` uses the `OBSIDIAN_VAULT` environment variable. If that variable is not set, it uses the current directory.
33
+
34
+ ```bash
35
+ export OBSIDIAN_VAULT="/path/to/your/vault"
36
+ ```
37
+
38
+ ### Initialize a note folder
39
+
40
+ ```bash
41
+ notekey init /path/to/vault/Projects/MyProject
42
+ ```
43
+
44
+ This creates:
45
+
46
+ - `MyProject.base`
47
+ - `MyProject.md`
48
+
49
+ Add extra base filters with comma-separated tags:
50
+
51
+ ```bash
52
+ notekey init /path/to/vault/Projects/MyProject --tags python,docs
53
+ ```
54
+
55
+ Overwrite existing generated files with:
56
+
57
+ ```bash
58
+ notekey init /path/to/vault/Projects/MyProject --force
59
+ ```
60
+
61
+ ### Search notes
62
+
63
+ Search by tag:
64
+
65
+ ```bash
66
+ notekey search --tags python
67
+ ```
68
+
69
+ Search by multiple tags. Files must match all tags:
70
+
71
+ ```bash
72
+ notekey search --tags python,web
73
+ ```
74
+
75
+ Search by filename:
76
+
77
+ ```bash
78
+ notekey search --filename flask
79
+ ```
80
+
81
+ Search by content:
82
+
83
+ ```bash
84
+ notekey search --content pandas
85
+ ```
86
+
87
+ Use `=` for exact matches:
88
+
89
+ ```bash
90
+ notekey search --tags "=python"
91
+ notekey search --filename "=deep/hidden-note"
92
+ ```
93
+
94
+ ### Read a note
95
+
96
+ ```bash
97
+ notekey read flask-app
98
+ ```
99
+
100
+ For an exact filename match:
101
+
102
+ ```bash
103
+ notekey read "=flask-app"
104
+ ```
105
+
106
+ ## Development
107
+
108
+ Run tests with:
109
+
110
+ ```bash
111
+ pytest
112
+ ```
113
+
114
+ Build distribution files with:
115
+
116
+ ```bash
117
+ python -m build
118
+ ```
119
+
120
+ Upload is intentionally manual. Use TestPyPI or PyPI with your API token when ready.
File without changes
@@ -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()
@@ -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
+ )
@@ -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
+ """