brainkeeper 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.
- brainkeeper/__init__.py +3 -0
- brainkeeper/__main__.py +5 -0
- brainkeeper/cli/__init__.py +45 -0
- brainkeeper/cli/init.py +52 -0
- brainkeeper/cli/serve.py +22 -0
- brainkeeper/core/__init__.py +0 -0
- brainkeeper/core/config.py +104 -0
- brainkeeper/core/frontmatter.py +72 -0
- brainkeeper/core/fs.py +47 -0
- brainkeeper/core/index.py +104 -0
- brainkeeper/core/rescanner.py +52 -0
- brainkeeper/core/watcher.py +85 -0
- brainkeeper/mcp/__init__.py +0 -0
- brainkeeper/mcp/server.py +99 -0
- brainkeeper/mcp/tools/__init__.py +0 -0
- brainkeeper/mcp/tools/convention.py +86 -0
- brainkeeper/mcp/tools/primitives.py +177 -0
- brainkeeper/mcp/tools/semantic.py +188 -0
- brainkeeper/spec/SPEC.md +313 -0
- brainkeeper/spec/__init__.py +0 -0
- brainkeeper/spec/examples/.gitkeep +0 -0
- brainkeeper/spec/examples/.invalid/README.md +14 -0
- brainkeeper/spec/examples/.invalid/absolute-path.yaml +11 -0
- brainkeeper/spec/examples/.invalid/missing-default.yaml +11 -0
- brainkeeper/spec/examples/.invalid/unknown-root-key.yaml +14 -0
- brainkeeper/spec/examples/daniels-vault.yaml +20 -0
- brainkeeper/spec/examples/minimal.yaml +14 -0
- brainkeeper/spec/examples/zettelkasten.yaml +18 -0
- brainkeeper/spec/schema/brainkeeper.schema.json +66 -0
- brainkeeper-0.1.0.dist-info/METADATA +218 -0
- brainkeeper-0.1.0.dist-info/RECORD +34 -0
- brainkeeper-0.1.0.dist-info/WHEEL +4 -0
- brainkeeper-0.1.0.dist-info/entry_points.txt +2 -0
- brainkeeper-0.1.0.dist-info/licenses/LICENSE +21 -0
brainkeeper/__init__.py
ADDED
brainkeeper/__main__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""brainkeeper CLI dispatcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import serve as _serve
|
|
9
|
+
from . import init as _init
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="brainkeeper",
|
|
15
|
+
description="brainkeeper vault tooling",
|
|
16
|
+
)
|
|
17
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
18
|
+
|
|
19
|
+
sp_serve = sub.add_parser("serve", help="Start the MCP server")
|
|
20
|
+
sp_serve.add_argument(
|
|
21
|
+
"--vault",
|
|
22
|
+
type=Path,
|
|
23
|
+
required=True,
|
|
24
|
+
help="Path to vault root containing brainkeeper.yaml",
|
|
25
|
+
)
|
|
26
|
+
sp_serve.add_argument("-v", "--verbose", action="store_true")
|
|
27
|
+
|
|
28
|
+
sp_init = sub.add_parser("init", help="Bootstrap a new vault")
|
|
29
|
+
sp_init.add_argument(
|
|
30
|
+
"path", type=Path, help="Path for the new vault root (created if absent)"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
args = parser.parse_args(argv)
|
|
34
|
+
|
|
35
|
+
if args.command == "serve":
|
|
36
|
+
return _serve.run(args)
|
|
37
|
+
if args.command == "init":
|
|
38
|
+
return _init.run(args)
|
|
39
|
+
|
|
40
|
+
parser.print_help()
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
sys.exit(main())
|
brainkeeper/cli/init.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""vault init subcommand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import importlib.resources
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_LAYER_DIRS = [
|
|
9
|
+
"00 Inbox",
|
|
10
|
+
"10 Journal",
|
|
11
|
+
"20 Projects",
|
|
12
|
+
"30 Areas",
|
|
13
|
+
"40 Brain",
|
|
14
|
+
"90 Archive",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _find_minimal_yaml() -> Path:
|
|
19
|
+
via_package = Path(
|
|
20
|
+
str(importlib.resources.files("brainkeeper.spec") / "examples" / "minimal.yaml")
|
|
21
|
+
)
|
|
22
|
+
if via_package.exists():
|
|
23
|
+
return via_package
|
|
24
|
+
# Dev fallback
|
|
25
|
+
dev_path = (
|
|
26
|
+
Path(__file__).resolve().parents[3] / "spec" / "examples" / "minimal.yaml"
|
|
27
|
+
)
|
|
28
|
+
if dev_path.exists():
|
|
29
|
+
return dev_path
|
|
30
|
+
raise FileNotFoundError("minimal.yaml not found. Run `pip install -e .` first.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run(args) -> int:
|
|
34
|
+
vault = args.path.expanduser().resolve()
|
|
35
|
+
vault.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
for layer in _LAYER_DIRS:
|
|
38
|
+
(vault / layer).mkdir(exist_ok=True)
|
|
39
|
+
|
|
40
|
+
config_path = vault / "brainkeeper.yaml"
|
|
41
|
+
if config_path.exists():
|
|
42
|
+
print(
|
|
43
|
+
f"warning: {config_path} already exists - skipping. "
|
|
44
|
+
"Edit it manually to change your vault configuration.",
|
|
45
|
+
file=sys.stderr,
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
minimal = _find_minimal_yaml()
|
|
49
|
+
config_path.write_text(minimal.read_text(encoding="utf-8"), encoding="utf-8")
|
|
50
|
+
print(f"created vault at {vault}")
|
|
51
|
+
|
|
52
|
+
return 0
|
brainkeeper/cli/serve.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""MCP serve subcommand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from ..mcp.server import BrainkeeperServer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run(args) -> int:
|
|
11
|
+
logging.basicConfig(
|
|
12
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
13
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
14
|
+
stream=sys.stderr,
|
|
15
|
+
)
|
|
16
|
+
vault = args.vault.expanduser().resolve()
|
|
17
|
+
if not vault.is_dir():
|
|
18
|
+
print(f"error: vault path is not a directory: {vault}", file=sys.stderr)
|
|
19
|
+
return 1
|
|
20
|
+
srv = BrainkeeperServer(vault)
|
|
21
|
+
srv.run_stdio()
|
|
22
|
+
return 0
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Load and validate brainkeeper.yaml against the JSON Schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from jsonschema import Draft202012Validator
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _find_schema() -> Path:
|
|
16
|
+
# Path inside an installed wheel (where force-include populated brainkeeper/spec/)
|
|
17
|
+
via_package = Path(
|
|
18
|
+
str(
|
|
19
|
+
importlib.resources.files("brainkeeper.spec")
|
|
20
|
+
/ "schema"
|
|
21
|
+
/ "brainkeeper.schema.json"
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
if via_package.exists():
|
|
25
|
+
return via_package
|
|
26
|
+
# Dev fallback: src/brainkeeper/core/config.py walks up to repo root
|
|
27
|
+
# parents: [0]=core/, [1]=brainkeeper/, [2]=src/, [3]=repo root
|
|
28
|
+
dev_path = (
|
|
29
|
+
Path(__file__).resolve().parents[3]
|
|
30
|
+
/ "spec"
|
|
31
|
+
/ "schema"
|
|
32
|
+
/ "brainkeeper.schema.json"
|
|
33
|
+
)
|
|
34
|
+
if dev_path.exists():
|
|
35
|
+
return dev_path
|
|
36
|
+
raise FileNotFoundError(
|
|
37
|
+
"brainkeeper.schema.json not found. "
|
|
38
|
+
"Run `pip install -e .` or build the wheel first."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_SCHEMA_PATH = _find_schema()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LayerOptions(BaseModel):
|
|
46
|
+
path: str
|
|
47
|
+
format: str | None = None
|
|
48
|
+
status_field: str | None = None
|
|
49
|
+
active_values: list[str] | None = None
|
|
50
|
+
year_subfolder: bool | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _coerce_layer(value: Any) -> LayerOptions:
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
return LayerOptions(path=value)
|
|
56
|
+
if isinstance(value, dict):
|
|
57
|
+
return LayerOptions(**value)
|
|
58
|
+
raise ValueError(f"layer entry must be str or dict: {value!r}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Layers(BaseModel):
|
|
62
|
+
inbox: LayerOptions
|
|
63
|
+
journal: LayerOptions
|
|
64
|
+
projects: LayerOptions
|
|
65
|
+
areas: LayerOptions
|
|
66
|
+
brain: LayerOptions
|
|
67
|
+
archive: LayerOptions
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_raw(cls, raw: dict[str, Any]) -> "Layers":
|
|
71
|
+
return cls(**{k: _coerce_layer(v) for k, v in raw.items()})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Config(BaseModel):
|
|
75
|
+
layers: Layers
|
|
76
|
+
capture_routing: dict[str, str]
|
|
77
|
+
vault_root: Path = Field(exclude=True)
|
|
78
|
+
|
|
79
|
+
def layer_path(self, key: str) -> Path:
|
|
80
|
+
opts: LayerOptions = getattr(self.layers, key)
|
|
81
|
+
return self.vault_root / opts.path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ConfigLoader:
|
|
85
|
+
"""Loads brainkeeper.yaml from a vault root and validates against schema."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, vault_root: Path) -> None:
|
|
88
|
+
self.vault_root = Path(vault_root)
|
|
89
|
+
self._validator = Draft202012Validator(json.loads(_SCHEMA_PATH.read_text()))
|
|
90
|
+
|
|
91
|
+
def load(self) -> Config:
|
|
92
|
+
path = self.vault_root / "brainkeeper.yaml"
|
|
93
|
+
if not path.exists():
|
|
94
|
+
raise FileNotFoundError(f"no brainkeeper.yaml at {self.vault_root}")
|
|
95
|
+
raw = yaml.safe_load(path.read_text()) or {}
|
|
96
|
+
errors = sorted(self._validator.iter_errors(raw), key=lambda e: e.path)
|
|
97
|
+
if errors:
|
|
98
|
+
msg = "; ".join(e.message for e in errors)
|
|
99
|
+
raise ValueError(f"brainkeeper.yaml fails schema: {msg}")
|
|
100
|
+
return Config(
|
|
101
|
+
layers=Layers.from_raw(raw["layers"]),
|
|
102
|
+
capture_routing=raw["capture_routing"],
|
|
103
|
+
vault_root=self.vault_root,
|
|
104
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""YAML frontmatter parsing + validation against brainkeeper spec v0.1.3."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import frontmatter
|
|
11
|
+
|
|
12
|
+
REQUIRED_FIELDS: tuple[str, ...] = ("created", "updated", "tags")
|
|
13
|
+
DATE_FIELDS: tuple[str, ...] = ("created", "updated")
|
|
14
|
+
|
|
15
|
+
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
16
|
+
_TAG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*(/[a-z0-9][a-z0-9-]*)*$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ValidationError(Exception):
|
|
20
|
+
"""Raised when frontmatter fails spec validation."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FrontmatterParser:
|
|
24
|
+
"""Thin wrapper over python-frontmatter with spec validation."""
|
|
25
|
+
|
|
26
|
+
def parse(self, path: Path) -> tuple[dict[str, Any], str]:
|
|
27
|
+
post = frontmatter.load(path)
|
|
28
|
+
meta = dict(post.metadata or {})
|
|
29
|
+
# PyYAML auto-coerces ISO dates into date/datetime objects.
|
|
30
|
+
# Normalize known date fields back to YYYY-MM-DD strings so downstream
|
|
31
|
+
# consumers and the spec-aligned validator see consistent string types.
|
|
32
|
+
for field in DATE_FIELDS:
|
|
33
|
+
v = meta.get(field)
|
|
34
|
+
if isinstance(v, (_dt.date, _dt.datetime)):
|
|
35
|
+
meta[field] = v.isoformat()[:10]
|
|
36
|
+
return meta, post.content
|
|
37
|
+
|
|
38
|
+
def validate(self, meta: dict[str, Any]) -> list[str]:
|
|
39
|
+
errors: list[str] = []
|
|
40
|
+
|
|
41
|
+
for field in REQUIRED_FIELDS:
|
|
42
|
+
value = meta.get(field)
|
|
43
|
+
if value in (None, "", []):
|
|
44
|
+
errors.append(f"required field `{field}` is missing or empty")
|
|
45
|
+
|
|
46
|
+
for field in DATE_FIELDS:
|
|
47
|
+
v = meta.get(field)
|
|
48
|
+
if v in (None, "", False):
|
|
49
|
+
continue
|
|
50
|
+
if not _DATE_RE.match(str(v)):
|
|
51
|
+
errors.append(f"`{field}` value '{v}' is not YYYY-MM-DD")
|
|
52
|
+
|
|
53
|
+
created = meta.get("created")
|
|
54
|
+
updated = meta.get("updated")
|
|
55
|
+
if (
|
|
56
|
+
isinstance(created, str)
|
|
57
|
+
and isinstance(updated, str)
|
|
58
|
+
and _DATE_RE.match(created)
|
|
59
|
+
and _DATE_RE.match(updated)
|
|
60
|
+
and updated < created
|
|
61
|
+
):
|
|
62
|
+
errors.append(
|
|
63
|
+
f"`updated` ({updated}) is earlier than `created` ({created})"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
tags = meta.get("tags")
|
|
67
|
+
if isinstance(tags, list):
|
|
68
|
+
for t in tags:
|
|
69
|
+
if not isinstance(t, str) or not _TAG_RE.match(t.lstrip("#")):
|
|
70
|
+
errors.append(f"tag '{t}' fails grammar")
|
|
71
|
+
|
|
72
|
+
return errors
|
brainkeeper/core/fs.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Atomic file writes with optional mtime check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StaleWriteError(Exception):
|
|
11
|
+
"""Raised when expected_mtime does not match the file's current mtime."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AtomicWriter:
|
|
15
|
+
"""tmp-file + os.replace for crash-safe writes."""
|
|
16
|
+
|
|
17
|
+
def write_atomic(
|
|
18
|
+
self,
|
|
19
|
+
path: Path,
|
|
20
|
+
content: str,
|
|
21
|
+
expected_mtime: float | None = None,
|
|
22
|
+
) -> float:
|
|
23
|
+
path = Path(path)
|
|
24
|
+
if expected_mtime is not None and path.exists():
|
|
25
|
+
actual = path.stat().st_mtime
|
|
26
|
+
if abs(actual - expected_mtime) > 1e-6:
|
|
27
|
+
raise StaleWriteError(
|
|
28
|
+
f"expected mtime {expected_mtime} but found {actual} for {path}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
33
|
+
prefix=path.name + ".",
|
|
34
|
+
suffix=".tmp",
|
|
35
|
+
dir=path.parent,
|
|
36
|
+
)
|
|
37
|
+
try:
|
|
38
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fp:
|
|
39
|
+
fp.write(content)
|
|
40
|
+
os.replace(tmp_path, path)
|
|
41
|
+
except Exception:
|
|
42
|
+
try:
|
|
43
|
+
os.unlink(tmp_path)
|
|
44
|
+
except OSError:
|
|
45
|
+
pass
|
|
46
|
+
raise
|
|
47
|
+
return path.stat().st_mtime
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""In-memory index of vault notes with frontmatter metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import RLock
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .frontmatter import FrontmatterParser
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class NoteMeta:
|
|
15
|
+
path: Path
|
|
16
|
+
frontmatter: dict[str, Any]
|
|
17
|
+
mtime: float
|
|
18
|
+
validation_errors: list[str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Index:
|
|
22
|
+
"""Thread-safe dict[Path, NoteMeta] with simple queries."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, vault_root: Path) -> None:
|
|
25
|
+
self.vault_root = Path(vault_root)
|
|
26
|
+
self._lock = RLock()
|
|
27
|
+
self._notes: dict[Path, NoteMeta] = {}
|
|
28
|
+
self._parser = FrontmatterParser()
|
|
29
|
+
|
|
30
|
+
def build(self) -> None:
|
|
31
|
+
with self._lock:
|
|
32
|
+
self._notes.clear()
|
|
33
|
+
for f in self.vault_root.rglob("*.md"):
|
|
34
|
+
parts = f.relative_to(self.vault_root).parts
|
|
35
|
+
if any(p.startswith(".") or p == "_templates" for p in parts):
|
|
36
|
+
continue
|
|
37
|
+
self._index_file(f)
|
|
38
|
+
|
|
39
|
+
def update(self, path: Path) -> None:
|
|
40
|
+
path = Path(path)
|
|
41
|
+
with self._lock:
|
|
42
|
+
if not path.exists():
|
|
43
|
+
self._notes.pop(path, None)
|
|
44
|
+
return
|
|
45
|
+
self._index_file(path)
|
|
46
|
+
|
|
47
|
+
def remove(self, path: Path) -> None:
|
|
48
|
+
with self._lock:
|
|
49
|
+
self._notes.pop(Path(path), None)
|
|
50
|
+
|
|
51
|
+
def get(self, path: Path) -> NoteMeta | None:
|
|
52
|
+
with self._lock:
|
|
53
|
+
return self._notes.get(Path(path))
|
|
54
|
+
|
|
55
|
+
def paths(self) -> list[Path]:
|
|
56
|
+
with self._lock:
|
|
57
|
+
return list(self._notes.keys())
|
|
58
|
+
|
|
59
|
+
def by_tag(self, tag: str) -> list[NoteMeta]:
|
|
60
|
+
with self._lock:
|
|
61
|
+
return [
|
|
62
|
+
m
|
|
63
|
+
for m in self._notes.values()
|
|
64
|
+
if isinstance(m.frontmatter.get("tags"), list)
|
|
65
|
+
and tag in m.frontmatter["tags"]
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
def by_status(self, status: str) -> list[NoteMeta]:
|
|
69
|
+
with self._lock:
|
|
70
|
+
return [
|
|
71
|
+
m
|
|
72
|
+
for m in self._notes.values()
|
|
73
|
+
if str(m.frontmatter.get("status")) == status
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
def by_type(self, type_value: str) -> list[NoteMeta]:
|
|
77
|
+
with self._lock:
|
|
78
|
+
return [
|
|
79
|
+
m
|
|
80
|
+
for m in self._notes.values()
|
|
81
|
+
if str(m.frontmatter.get("type")) == type_value
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
def orphans(self) -> list[NoteMeta]:
|
|
85
|
+
"""Notes with any validation error against the spec."""
|
|
86
|
+
with self._lock:
|
|
87
|
+
return [m for m in self._notes.values() if m.validation_errors]
|
|
88
|
+
|
|
89
|
+
def _index_file(self, path: Path) -> None:
|
|
90
|
+
try:
|
|
91
|
+
meta, _ = self._parser.parse(path)
|
|
92
|
+
except Exception:
|
|
93
|
+
meta = {}
|
|
94
|
+
errors = self._parser.validate(meta)
|
|
95
|
+
try:
|
|
96
|
+
mtime = path.stat().st_mtime
|
|
97
|
+
except OSError:
|
|
98
|
+
return
|
|
99
|
+
self._notes[path] = NoteMeta(
|
|
100
|
+
path=path,
|
|
101
|
+
frontmatter=meta,
|
|
102
|
+
mtime=mtime,
|
|
103
|
+
validation_errors=errors,
|
|
104
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Periodic safety-net rescanner that walks the vault and reconciles Index."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .index import Index
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PeriodicRescanner:
|
|
15
|
+
def __init__(
|
|
16
|
+
self, vault_root: Path, index: Index, interval_seconds: int = 300
|
|
17
|
+
) -> None:
|
|
18
|
+
self.vault_root = Path(vault_root)
|
|
19
|
+
self.index = index
|
|
20
|
+
self.interval = interval_seconds
|
|
21
|
+
self._stop = threading.Event()
|
|
22
|
+
self._thread: threading.Thread | None = None
|
|
23
|
+
|
|
24
|
+
def start(self) -> None:
|
|
25
|
+
self._stop.clear()
|
|
26
|
+
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
27
|
+
self._thread.start()
|
|
28
|
+
|
|
29
|
+
def stop(self) -> None:
|
|
30
|
+
self._stop.set()
|
|
31
|
+
if self._thread:
|
|
32
|
+
self._thread.join(timeout=5)
|
|
33
|
+
|
|
34
|
+
def rescan_once(self) -> None:
|
|
35
|
+
on_disk: set[Path] = set()
|
|
36
|
+
for f in self.vault_root.rglob("*.md"):
|
|
37
|
+
parts = f.relative_to(self.vault_root).parts
|
|
38
|
+
if any(p.startswith(".") or p == "_templates" for p in parts):
|
|
39
|
+
continue
|
|
40
|
+
on_disk.add(f)
|
|
41
|
+
self.index.update(f)
|
|
42
|
+
in_index = set(self.index.paths())
|
|
43
|
+
for missing in in_index - on_disk:
|
|
44
|
+
log.info("rescan: removing missing file from index: %s", missing)
|
|
45
|
+
self.index.remove(missing)
|
|
46
|
+
|
|
47
|
+
def _loop(self) -> None:
|
|
48
|
+
while not self._stop.wait(self.interval):
|
|
49
|
+
try:
|
|
50
|
+
self.rescan_once()
|
|
51
|
+
except Exception:
|
|
52
|
+
log.exception("rescan iteration failed")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Filesystem watcher pushing updates into Index, debounced."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
9
|
+
from watchdog.observers import Observer
|
|
10
|
+
|
|
11
|
+
from .index import Index
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Handler(FileSystemEventHandler):
|
|
15
|
+
def __init__(self, vault_root: Path, index: Index, debounce_ms: int) -> None:
|
|
16
|
+
self.vault_root = vault_root
|
|
17
|
+
self.index = index
|
|
18
|
+
self.debounce = debounce_ms / 1000.0
|
|
19
|
+
self._timers: dict[Path, threading.Timer] = {}
|
|
20
|
+
self._lock = threading.Lock()
|
|
21
|
+
|
|
22
|
+
def _schedule(self, path: Path, deleted: bool) -> None:
|
|
23
|
+
if path.suffix != ".md":
|
|
24
|
+
return
|
|
25
|
+
try:
|
|
26
|
+
rel = path.relative_to(self.vault_root)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return
|
|
29
|
+
if any(p.startswith(".") or p == "_templates" for p in rel.parts):
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
def _fire():
|
|
33
|
+
with self._lock:
|
|
34
|
+
self._timers.pop(path, None)
|
|
35
|
+
if deleted:
|
|
36
|
+
self.index.remove(path)
|
|
37
|
+
else:
|
|
38
|
+
self.index.update(path)
|
|
39
|
+
|
|
40
|
+
with self._lock:
|
|
41
|
+
t = self._timers.get(path)
|
|
42
|
+
if t:
|
|
43
|
+
t.cancel()
|
|
44
|
+
timer = threading.Timer(self.debounce, _fire)
|
|
45
|
+
timer.daemon = True
|
|
46
|
+
self._timers[path] = timer
|
|
47
|
+
timer.start()
|
|
48
|
+
|
|
49
|
+
def on_created(self, event: FileSystemEvent) -> None:
|
|
50
|
+
if event.is_directory:
|
|
51
|
+
return
|
|
52
|
+
self._schedule(Path(event.src_path), deleted=False)
|
|
53
|
+
|
|
54
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
55
|
+
if event.is_directory:
|
|
56
|
+
return
|
|
57
|
+
self._schedule(Path(event.src_path), deleted=False)
|
|
58
|
+
|
|
59
|
+
def on_deleted(self, event: FileSystemEvent) -> None:
|
|
60
|
+
if event.is_directory:
|
|
61
|
+
return
|
|
62
|
+
self._schedule(Path(event.src_path), deleted=True)
|
|
63
|
+
|
|
64
|
+
def on_moved(self, event: FileSystemEvent) -> None:
|
|
65
|
+
if event.is_directory:
|
|
66
|
+
return
|
|
67
|
+
self._schedule(Path(event.src_path), deleted=True)
|
|
68
|
+
self._schedule(Path(event.dest_path), deleted=False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FileWatcher:
|
|
72
|
+
def __init__(self, vault_root: Path, index: Index, debounce_ms: int = 200) -> None:
|
|
73
|
+
self.vault_root = Path(vault_root)
|
|
74
|
+
self.index = index
|
|
75
|
+
self.debounce_ms = debounce_ms
|
|
76
|
+
self._observer = Observer()
|
|
77
|
+
self._handler = _Handler(self.vault_root, index, debounce_ms)
|
|
78
|
+
|
|
79
|
+
def start(self) -> None:
|
|
80
|
+
self._observer.schedule(self._handler, str(self.vault_root), recursive=True)
|
|
81
|
+
self._observer.start()
|
|
82
|
+
|
|
83
|
+
def stop(self) -> None:
|
|
84
|
+
self._observer.stop()
|
|
85
|
+
self._observer.join(timeout=5)
|
|
File without changes
|