hill-mdvault 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.
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.3
2
+ Name: hill-mdvault
3
+ Version: 0.1.0
4
+ Summary: Read and change an Obsidian-style vault of Markdown notes: frontmatter, [[wikilinks]], backlinks, activity
5
+ Requires-Dist: pyyaml>=6
6
+ Requires-Python: >=3.11
@@ -0,0 +1,13 @@
1
+ [project]
2
+ name = "hill-mdvault"
3
+ version = "0.1.0"
4
+ description = "Read and change an Obsidian-style vault of Markdown notes: frontmatter, [[wikilinks]], backlinks, activity"
5
+ requires-python = ">=3.11"
6
+ dependencies = ["pyyaml>=6"]
7
+
8
+ [build-system]
9
+ requires = ["uv_build>=0.12.19,<0.13.0"]
10
+ build-backend = "uv_build"
11
+
12
+ [tool.uv.build-backend]
13
+ module-name = "mdvault"
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "hill-mdvault"
3
+ version = "0.1.0"
4
+ description = "Read and change an Obsidian-style vault of Markdown notes: frontmatter, [[wikilinks]], backlinks, activity"
5
+ requires-python = ">=3.11"
6
+ dependencies = ["pyyaml>=6"]
7
+
8
+ [build-system]
9
+ requires = ["uv_build>=0.12.19,<0.13.0"]
10
+ build-backend = "uv_build"
11
+
12
+ # On PyPI it's hill-mdvault, as mdvault is taken; the import stays mdvault.
13
+ [tool.uv.build-backend]
14
+ module-name = "mdvault"
@@ -0,0 +1,398 @@
1
+ """Read and change an Obsidian-style vault: a folder of Markdown notes with
2
+ optional YAML frontmatter, linked by [[wikilinks]] or ordinary Markdown links.
3
+
4
+ No UI, no index files: the folder is the source of truth, so the same vault
5
+ works in Obsidian, a text editor or anything built on this.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from collections import Counter
13
+ from urllib.parse import unquote
14
+ from dataclasses import dataclass, field
15
+ from datetime import date, datetime
16
+ from pathlib import Path
17
+
18
+ import yaml
19
+
20
+ __all__ = [
21
+ "SKIP_DIRS", "Link", "LinkGraph", "Note", "Vault",
22
+ "markdown_links", "parse", "safe_filename", "set_fields", "wikilinks",
23
+ ]
24
+
25
+ # [[Target]], [[Target|shown text]], [[Target#Heading]], [[Target#Heading|shown]],
26
+ # or [text](target), [text](<target with spaces>), [text](target "title");
27
+ # not images, ![alt](picture.png).
28
+ _LINK = re.compile(
29
+ r"\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]"
30
+ r"|(?<!!)\[[^\]]*\]\(\s*(<[^>]*>|[^)\s]+)(?:\s+\"[^\"]*\")?\s*\)"
31
+ )
32
+ # Fenced code blocks and inline code: links in them are examples, not links.
33
+ _CODE = re.compile(r"(?ms)^(```|~~~).*?^\1|`[^`\n]*`")
34
+ _SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
35
+ _UNSAFE = re.compile(r'[/\\:*?"<>|]')
36
+
37
+
38
+ def parse(text: str) -> tuple[dict, str]:
39
+ """Split a note into its frontmatter (a dict, empty if none) and body."""
40
+ if text.startswith("---\n") or text.startswith("---\r\n"):
41
+ end = re.search(r"\r?\n---[ \t]*(\r?\n|$)", text[3:])
42
+ if end:
43
+ raw = text[3 : 3 + end.start()]
44
+ try:
45
+ meta = yaml.safe_load(raw) or {}
46
+ except yaml.YAMLError:
47
+ return {}, text
48
+ if isinstance(meta, dict):
49
+ return meta, text[3 + end.end() :]
50
+ return {}, text
51
+
52
+
53
+ def set_fields(text: str, fields: dict) -> str:
54
+ """`text` with each of `fields` set in its frontmatter, one line each:
55
+ a key's line is replaced where it has one, else the key is added at the
56
+ end; a note without frontmatter gets some. Everything else stays as it
57
+ was, comments and order included."""
58
+ lines = {key: f"{key}: " + yaml.safe_dump(value, default_flow_style=True).splitlines()[0]
59
+ for key, value in fields.items()}
60
+ end = re.search(r"\r?\n---[ \t]*(\r?\n|$)", text[3:]) if text.startswith("---\n") or text.startswith("---\r\n") else None
61
+ if end is None:
62
+ return "---\n" + "".join(f"{line}\n" for line in lines.values()) + "---\n" + text
63
+ head, rest = text[: 3 + end.start()], text[3 + end.start() :]
64
+ for key, line in lines.items():
65
+ pattern = re.compile(rf"(?m)^{re.escape(key)}:.*$")
66
+ head = pattern.sub(lambda _: line, head, count=1) if pattern.search(head) else f"{head}\n{line}"
67
+ return head + rest
68
+
69
+
70
+ def _links(text: str) -> list[tuple[str, str]]:
71
+ """("wiki", title) and ("markdown", target) pairs, in order, outside code."""
72
+ found = []
73
+ for m in _LINK.finditer(_CODE.sub(" ", text)):
74
+ if m.group(1) is not None:
75
+ found.append(("wiki", m.group(1).strip()))
76
+ else:
77
+ found.append(("markdown", m.group(2)))
78
+ return found
79
+
80
+
81
+ def wikilinks(text: str) -> list[str]:
82
+ """[[link]] targets in order, without headings or aliases, outside code."""
83
+ return [target for kind, target in _links(text) if kind == "wiki"]
84
+
85
+
86
+ def _local_target(target: str) -> str | None:
87
+ """A Markdown link's target as a local path: anchors and <> dropped,
88
+ %-escapes decoded; None for web addresses and same-page anchors."""
89
+ target = target.strip().removeprefix("<").removesuffix(">")
90
+ if _SCHEME.match(target):
91
+ return None
92
+ target = unquote(target.partition("#")[0].partition("?")[0])
93
+ return target or None
94
+
95
+
96
+ def markdown_links(text: str) -> list[str]:
97
+ """Local targets of ordinary Markdown links, in order, outside code."""
98
+ targets = (_local_target(t) for kind, t in _links(text) if kind == "markdown")
99
+ return [t for t in targets if t is not None]
100
+
101
+
102
+ def safe_filename(title: str) -> str:
103
+ """A file name for a title: characters file systems reject become '-'."""
104
+ name = _UNSAFE.sub("-", title).strip().strip(".")
105
+ return name or "Untitled"
106
+
107
+
108
+ def _tags(value: object) -> list[str]:
109
+ if isinstance(value, str):
110
+ return [t.strip().lstrip("#") for t in value.split(",") if t.strip()]
111
+ if isinstance(value, list):
112
+ return [str(t).lstrip("#") for t in value if t is not None]
113
+ return []
114
+
115
+
116
+ def _date(value: object) -> date | None:
117
+ """A frontmatter date: YAML reads 2026-10-05 as one, quoted it's text."""
118
+ if isinstance(value, datetime):
119
+ return value.date()
120
+ if isinstance(value, date):
121
+ return value
122
+ try:
123
+ return date.fromisoformat(str(value).strip()) if value else None
124
+ except ValueError:
125
+ return None
126
+
127
+
128
+ @dataclass
129
+ class Note:
130
+ path: Path
131
+ relpath: str
132
+ """Path inside the vault, with '/' separators, e.g. "projects/garden.md"."""
133
+ title: str
134
+ tags: list[str] = field(default_factory=list)
135
+ refs: list[tuple[str, str]] = field(default_factory=list)
136
+ """Links in order: ("wiki", title) for [[links]], ("path", path in the
137
+ vault) for Markdown links to notes, even ones that don't exist yet."""
138
+ modified: datetime = field(default_factory=datetime.now)
139
+ pinned: bool = False
140
+ status: str = ""
141
+ """The frontmatter's `status:`, lowercased, e.g. "waiting"; "" if none."""
142
+ since: date | None = None
143
+ """The frontmatter's `since:`, the day the status last changed; None if
144
+ none, or not a date."""
145
+ decisions: int = 0
146
+ """How many decisions wait in the body's `## Before` section: its
147
+ unticked `- [ ] Decide:` lines."""
148
+
149
+ @property
150
+ def links(self) -> list[str]:
151
+ """The [[link]] targets."""
152
+ return [target for kind, target in self.refs if kind == "wiki"]
153
+
154
+ @property
155
+ def folder(self) -> str:
156
+ return self.relpath.rpartition("/")[0]
157
+
158
+ def read(self) -> tuple[dict, str]:
159
+ """The note's frontmatter and body, read fresh from disk."""
160
+ return parse(self.path.read_text(errors="replace"))
161
+
162
+ @property
163
+ def body(self) -> str:
164
+ return self.read()[1]
165
+
166
+
167
+ def _note_path(root: Path, note: Path, target: str, suffixes: tuple[str, ...]) -> str | None:
168
+ """Where a Markdown link from `note` points, as a path in the vault, if
169
+ it points to a note there (a folder counts as its README)."""
170
+ base = root if target.startswith("/") else note.parent
171
+ path = Path(os.path.normpath(base / target.lstrip("/")))
172
+ if os.path.commonpath([root, path]) != str(root):
173
+ return None
174
+ if path.is_dir():
175
+ path = next((path / n for n in ("README.md", "readme.md", "index.md") if (path / n).is_file()), path)
176
+ if path.suffix not in suffixes:
177
+ return None
178
+ return path.relative_to(root).as_posix()
179
+
180
+
181
+ def _load(root: Path, path: Path, suffixes: tuple[str, ...] = (".md",)) -> Note:
182
+ try:
183
+ text = path.read_text(errors="replace")
184
+ except OSError:
185
+ text = ""
186
+ meta, body = parse(text)
187
+ title = meta.get("title")
188
+ updated = meta.get("updated_at")
189
+ if isinstance(updated, (int, float)) and not isinstance(updated, bool):
190
+ modified = datetime.fromtimestamp(updated)
191
+ else:
192
+ modified = datetime.fromtimestamp(path.stat().st_mtime)
193
+ return Note(
194
+ path=path,
195
+ relpath=path.relative_to(root).as_posix(),
196
+ title=str(title) if title else path.stem,
197
+ tags=_tags(meta.get("tags")),
198
+ refs=_refs(root, path, body, suffixes),
199
+ modified=modified,
200
+ pinned=meta.get("pinned") is True,
201
+ status=str(meta.get("status") or "").strip().casefold(),
202
+ since=_date(meta.get("since")),
203
+ decisions=_decisions(body),
204
+ )
205
+
206
+
207
+ # A `## Before` section, up to the next heading of its level or above.
208
+ _BEFORE = re.compile(r"(?ims)^##[ \t]+Before[ \t]*$(.*?)(?=^#{1,2}[ \t]|\Z)")
209
+ _DECIDE = re.compile(r"(?m)^[ \t]*[-*+][ \t]+\[ \][ \t]+Decide:")
210
+
211
+
212
+ def _decisions(body: str) -> int:
213
+ """The unticked `- [ ] Decide:` lines in a body's `## Before` section,
214
+ outside code."""
215
+ before = _BEFORE.search(_CODE.sub(" ", body))
216
+ return len(_DECIDE.findall(before.group(1))) if before else 0
217
+
218
+
219
+ def _refs(root: Path, path: Path, body: str, suffixes: tuple[str, ...]) -> list[tuple[str, str]]:
220
+ refs = []
221
+ for kind, target in _links(body):
222
+ if kind == "wiki":
223
+ refs.append(("wiki", target))
224
+ elif (local := _local_target(target)) and (where := _note_path(root, path, local, suffixes)):
225
+ refs.append(("path", where))
226
+ return refs
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class Link:
231
+ target: str
232
+ """A [[link]]'s title as written, or a Markdown link's path in the vault."""
233
+ note: Note | None
234
+ """The note it points to, or None if there is no such note yet."""
235
+ path: str | None = None
236
+ """For a Markdown link, the path in the vault it points to."""
237
+
238
+
239
+ class LinkGraph:
240
+ """Who links to whom, resolved once for a list of notes."""
241
+
242
+ def __init__(self, notes: list[Note]) -> None:
243
+ self.notes = notes
244
+ index: dict[str, Note] = {}
245
+ for note in notes:
246
+ for key in (note.title, note.path.stem, note.relpath.removesuffix(".md")):
247
+ index.setdefault(key.casefold(), note)
248
+ paths = {n.relpath.casefold(): n for n in notes}
249
+ self._out: dict[str, list[Link]] = {}
250
+ self._in: dict[str, list[Note]] = {n.relpath: [] for n in notes}
251
+ for note in notes:
252
+ links, seen = [], set()
253
+ for kind, target in note.refs:
254
+ found = (index if kind == "wiki" else paths).get(target.casefold())
255
+ # Each note once, however many times and ways it's linked.
256
+ key = found.relpath if found is not None else (kind, target.casefold())
257
+ if key in seen:
258
+ continue
259
+ seen.add(key)
260
+ links.append(Link(target, found, target if kind == "path" else None))
261
+ if found is not None and found is not note:
262
+ self._in[found.relpath].append(note)
263
+ self._out[note.relpath] = links
264
+
265
+ def links_from(self, note: Note) -> list[Link]:
266
+ """The note's links, in order, each once, including missing notes."""
267
+ return self._out.get(note.relpath, [])
268
+
269
+ def links_to(self, note: Note) -> list[Note]:
270
+ """Notes that link to `note`."""
271
+ return self._in.get(note.relpath, [])
272
+
273
+ def unlinked(self, exclude: Note | None = None) -> list[Note]:
274
+ """Notes nothing links to, apart from `exclude` (e.g. the home note)."""
275
+ return [n for n in self.notes if not self._in.get(n.relpath) and n is not exclude]
276
+
277
+
278
+ # Folders of code projects that hold dependencies or build output, whose
279
+ # READMEs and changelogs aren't notes.
280
+ SKIP_DIRS = frozenset(
281
+ "node_modules target build dist venv site-packages vendor __pycache__".split()
282
+ )
283
+
284
+
285
+ class Vault:
286
+ def __init__(
287
+ self,
288
+ root: Path | str,
289
+ suffixes: tuple[str, ...] = (".md",),
290
+ skip_dirs: frozenset[str] = SKIP_DIRS,
291
+ ) -> None:
292
+ self.root = Path(root).expanduser()
293
+ self.suffixes = suffixes
294
+ self.skip_dirs = skip_dirs
295
+
296
+ def _paths(self):
297
+ for dirpath, dirs, files in os.walk(self.root):
298
+ dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d not in self.skip_dirs)
299
+ for name in files:
300
+ if not name.startswith(".") and Path(name).suffix in self.suffixes:
301
+ yield Path(dirpath) / name
302
+
303
+ def notes(self) -> list[Note]:
304
+ """Every note, sorted by title. Hidden files and folders, and
305
+ dependency and build folders (`skip_dirs`), are skipped."""
306
+ found = [_load(self.root, path, self.suffixes) for path in self._paths()]
307
+ return sorted(found, key=lambda n: (n.title.casefold(), n.relpath))
308
+
309
+ def stamp(self) -> tuple:
310
+ """What the vault looks like, without reading its notes: their paths,
311
+ sizes and modification times, and the top-level folders. While it
312
+ stays the same, so do `notes()` and `folders()`, so a program can
313
+ poll it to notice changes made by others."""
314
+ found = []
315
+ for path in self._paths():
316
+ try:
317
+ st = path.stat()
318
+ except OSError:
319
+ continue
320
+ found.append((path.relative_to(self.root).as_posix(), st.st_size, st.st_mtime_ns))
321
+ return tuple(sorted(found)), tuple(self.folders())
322
+
323
+ def folders(self) -> list[str]:
324
+ """The vault's top-level folders, notes or not (in a folder of
325
+ projects, the projects), skipping the same folders as `notes()`."""
326
+ try:
327
+ entries = list(os.scandir(self.root))
328
+ except OSError:
329
+ return []
330
+ return sorted(
331
+ (e.name for e in entries if e.is_dir() and not e.name.startswith(".") and e.name not in self.skip_dirs),
332
+ key=str.casefold,
333
+ )
334
+
335
+ def resolve(self, link: str, notes: list[Note] | None = None) -> Note | None:
336
+ """The note a [[link]] points to: by title, else by path."""
337
+ notes = self.notes() if notes is None else notes
338
+ key = link.strip().casefold()
339
+ for note in notes:
340
+ if note.title.casefold() == key:
341
+ return note
342
+ for note in notes:
343
+ if note.relpath.casefold().removesuffix(".md") == key.removesuffix(".md"):
344
+ return note
345
+ return None
346
+
347
+ def backlinks(self, note: Note, notes: list[Note] | None = None) -> list[Note]:
348
+ """Notes that link to `note`."""
349
+ return self.graph(notes).links_to(note)
350
+
351
+ def graph(self, notes: list[Note] | None = None) -> LinkGraph:
352
+ """Links between notes, resolved once."""
353
+ return LinkGraph(self.notes() if notes is None else notes)
354
+
355
+ def activity(self, notes: list[Note] | None = None) -> Counter[date]:
356
+ """How many notes were last changed on each day."""
357
+ notes = self.notes() if notes is None else notes
358
+ return Counter(n.modified.date() for n in notes)
359
+
360
+ def _free_path(self, folder: str, title: str, suffix: str, keep: Path | None = None) -> Path:
361
+ base = self.root / folder if folder else self.root
362
+ stem = safe_filename(title)
363
+ path = base / f"{stem}{suffix}"
364
+ n = 2
365
+ while path.exists() and path != keep:
366
+ path = base / f"{stem} {n}{suffix}"
367
+ n += 1
368
+ return path
369
+
370
+ def create(self, title: str, folder: str = "") -> Note:
371
+ """A new, empty note named after `title` (never overwrites a note)."""
372
+ path = self._free_path(folder, title, ".md")
373
+ path.parent.mkdir(parents=True, exist_ok=True)
374
+ with path.open("x"):
375
+ pass
376
+ return _load(self.root, path, self.suffixes)
377
+
378
+ def update(self, note: Note, fields: dict) -> Note:
379
+ """Set `fields` in `note`'s frontmatter (set_fields), replacing the
380
+ file in one go, and give the note as it is now."""
381
+ text = set_fields(note.path.read_text(errors="replace"), fields)
382
+ new = note.path.with_name(f".{note.path.name}.{os.getpid()}")
383
+ new.write_text(text)
384
+ new.replace(note.path)
385
+ return _load(self.root, note.path, self.suffixes)
386
+
387
+ def rename(self, note: Note, title: str) -> Note:
388
+ """Give `note` a new title: rename its file, and update a `title:`
389
+ in its frontmatter if it has one. Links to it are not rewritten."""
390
+ text = note.path.read_text(errors="replace")
391
+ meta, _ = parse(text)
392
+ if "title" in meta:
393
+ text = re.sub(r"(?m)^title:.*$", "title: " + yaml.safe_dump(title).splitlines()[0], text, count=1)
394
+ note.path.write_text(text)
395
+ new = self._free_path(note.folder, title, note.path.suffix, keep=note.path)
396
+ if new != note.path:
397
+ os.rename(note.path, new)
398
+ return _load(self.root, new, self.suffixes)