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,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, .
|
|
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)
|