mindslice 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.
- mindslice/__init__.py +58 -0
- mindslice/__main__.py +5 -0
- mindslice/baking/__init__.py +1 -0
- mindslice/baking/archive.py +66 -0
- mindslice/baking/bake.py +299 -0
- mindslice/baking/bake_batch.py +537 -0
- mindslice/baking/slice_transfer.py +265 -0
- mindslice/errors.py +104 -0
- mindslice/interfaces/__init__.py +1 -0
- mindslice/interfaces/bake_review.py +230 -0
- mindslice/interfaces/cli.py +392 -0
- mindslice/interfaces/cli_addressing.py +52 -0
- mindslice/interfaces/doctor.py +184 -0
- mindslice/interfaces/mcp_server.py +536 -0
- mindslice/interfaces/render.py +346 -0
- mindslice/interfaces/staging_review.py +92 -0
- mindslice/model/__init__.py +1 -0
- mindslice/model/addressing.py +251 -0
- mindslice/model/card.py +641 -0
- mindslice/model/lineage.py +228 -0
- mindslice/model/text.py +19 -0
- mindslice/model/tomlfmt.py +129 -0
- mindslice/operations/__init__.py +1 -0
- mindslice/operations/audit.py +195 -0
- mindslice/operations/boot.py +183 -0
- mindslice/operations/impact.py +98 -0
- mindslice/operations/recovery.py +113 -0
- mindslice/operations/staging.py +641 -0
- mindslice/operations/status.py +137 -0
- mindslice/operations/validation.py +250 -0
- mindslice/operations/writer.py +481 -0
- mindslice/retrieval/__init__.py +1 -0
- mindslice/retrieval/index.py +634 -0
- mindslice/retrieval/lexical.py +215 -0
- mindslice/retrieval/query.py +593 -0
- mindslice/storage/__init__.py +1 -0
- mindslice/storage/card_io.py +14 -0
- mindslice/storage/config.py +192 -0
- mindslice/storage/fsutil.py +141 -0
- mindslice/storage/history.py +182 -0
- mindslice/storage/locking.py +125 -0
- mindslice/storage/snapshots.py +262 -0
- mindslice/workspace.py +385 -0
- mindslice-0.1.0.dist-info/METADATA +98 -0
- mindslice-0.1.0.dist-info/RECORD +50 -0
- mindslice-0.1.0.dist-info/WHEEL +5 -0
- mindslice-0.1.0.dist-info/entry_points.txt +2 -0
- mindslice-0.1.0.dist-info/licenses/LICENSE +202 -0
- mindslice-0.1.0.dist-info/licenses/NOTICE +22 -0
- mindslice-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Address parsing, card-path validation and reference resolution.
|
|
2
|
+
|
|
3
|
+
Addresses come in two forms:
|
|
4
|
+
|
|
5
|
+
* namespace-qualified: ``@alias/relative/path``
|
|
6
|
+
* unqualified / slice-relative: ``relative/path``
|
|
7
|
+
|
|
8
|
+
When an unqualified reference is written *inside a card* (``extends``,
|
|
9
|
+
``supersedes`` or an inline ``[[wikilink]]``) it resolves strictly within the
|
|
10
|
+
card's own slice, never through global precedence (design section 3.2).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Iterable
|
|
18
|
+
|
|
19
|
+
from ..errors import InvalidAddressError
|
|
20
|
+
from .text import fold
|
|
21
|
+
|
|
22
|
+
_ALIAS_RE = re.compile(r"^@[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
23
|
+
_SEGMENT_RE = re.compile(r"^[\w][\w .-]*$", re.UNICODE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_alias(alias: str) -> str:
|
|
27
|
+
alias = (alias or "").strip()
|
|
28
|
+
if not alias.startswith("@"):
|
|
29
|
+
alias = "@" + alias
|
|
30
|
+
if not _ALIAS_RE.match(alias):
|
|
31
|
+
raise InvalidAddressError(f"invalid mount alias: {alias!r} (expected @name using letters, digits, '_', '.', '-')")
|
|
32
|
+
return alias
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def alias_from_name(name: str) -> str:
|
|
36
|
+
"""A valid alias derived from a directory or slice name (``My Notes`` -> ``@My-Notes``)."""
|
|
37
|
+
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", name.removeprefix("@")).strip("-.") or "slice"
|
|
38
|
+
if not cleaned[0].isalnum():
|
|
39
|
+
cleaned = "s" + cleaned
|
|
40
|
+
return normalize_alias(cleaned)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _control(seg: str) -> bool:
|
|
44
|
+
return any(ord(c) < 0x20 or 0x7F <= ord(c) < 0xA0 for c in seg)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_path(path: str, strict: bool = False) -> str:
|
|
48
|
+
"""Normalize a slice-relative card path (POSIX separators, no ``.md``).
|
|
49
|
+
|
|
50
|
+
Any existing file name is addressable (``2024-01-01 (draft) & notes``): only empty, ``.``/``..``,
|
|
51
|
+
hidden (leading-dot), space-padded and control-character segments are rejected. ``strict=True`` is
|
|
52
|
+
for naming a *new* card: segments must also be letters, digits, ``_``, ``-``, ``.`` and spaces, so
|
|
53
|
+
the id works as a ``[[wikilink]]`` target everywhere.
|
|
54
|
+
"""
|
|
55
|
+
raw = path
|
|
56
|
+
path = (path or "").strip().replace("\\", "/")
|
|
57
|
+
while path.startswith("./"):
|
|
58
|
+
path = path[2:]
|
|
59
|
+
path = path.strip("/")
|
|
60
|
+
if path.endswith(".md"):
|
|
61
|
+
path = path[:-3]
|
|
62
|
+
segments = [s for s in path.split("/") if s not in ("",)]
|
|
63
|
+
if not segments:
|
|
64
|
+
raise InvalidAddressError(f"empty card path: {raw!r}")
|
|
65
|
+
for seg in segments:
|
|
66
|
+
if seg in (".", ".."):
|
|
67
|
+
raise InvalidAddressError(f"card path may not contain '.' or '..' segments: {raw!r}")
|
|
68
|
+
if seg.startswith(".") or seg != seg.strip() or _control(seg) or (strict and not _SEGMENT_RE.match(seg)):
|
|
69
|
+
raise InvalidAddressError(
|
|
70
|
+
f"invalid card path segment {seg!r} in {raw!r} (use letters, digits, '_', '-', '.', spaces; no leading dot)"
|
|
71
|
+
)
|
|
72
|
+
return "/".join(segments)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_address(address: str) -> tuple[str | None, str]:
|
|
76
|
+
"""Split ``@alias/path`` into ``(alias, path)``; unqualified paths give ``(None, path)``."""
|
|
77
|
+
if not isinstance(address, str):
|
|
78
|
+
raise InvalidAddressError(f"address must be a string, got {type(address).__name__}")
|
|
79
|
+
address = address.strip()
|
|
80
|
+
if address.startswith("@"):
|
|
81
|
+
alias, sep, rest = address.partition("/")
|
|
82
|
+
if not sep or not rest.strip("/"):
|
|
83
|
+
raise InvalidAddressError(f"qualified address needs a path after the alias: {address!r}")
|
|
84
|
+
return normalize_alias(alias), normalize_path(rest)
|
|
85
|
+
return None, normalize_path(address)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def format_address(alias: str, path: str) -> str:
|
|
89
|
+
return f"{alias}/{path}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Wikilinks
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
WIKILINK_RE = re.compile(r"\[\[([^\[\]\|\n]+?)(\|[^\[\]\n]*)?\]\]")
|
|
97
|
+
_FENCE_RE = re.compile(r"^(\s*)(`{3,}|~{3,})")
|
|
98
|
+
_INLINE_CODE_RE = re.compile(r"(`+)(?:(?!\1).)+?\1")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _code_mask(text: str) -> list[tuple[int, int]]:
|
|
102
|
+
"""Return character spans that are inside fenced code blocks or inline code."""
|
|
103
|
+
spans: list[tuple[int, int]] = []
|
|
104
|
+
pos = 0
|
|
105
|
+
fence: str | None = None
|
|
106
|
+
fence_start = 0
|
|
107
|
+
for line in text.splitlines(keepends=True):
|
|
108
|
+
m = _FENCE_RE.match(line)
|
|
109
|
+
if fence is None and m:
|
|
110
|
+
fence = m.group(2)[0] * 3
|
|
111
|
+
fence_start = pos
|
|
112
|
+
elif fence is not None and m and m.group(2).startswith(fence):
|
|
113
|
+
spans.append((fence_start, pos + len(line)))
|
|
114
|
+
fence = None
|
|
115
|
+
elif fence is None:
|
|
116
|
+
for im in _INLINE_CODE_RE.finditer(line):
|
|
117
|
+
spans.append((pos + im.start(), pos + im.end()))
|
|
118
|
+
pos += len(line)
|
|
119
|
+
if fence is not None:
|
|
120
|
+
spans.append((fence_start, len(text)))
|
|
121
|
+
return spans
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _in_spans(idx: int, spans: list[tuple[int, int]]) -> bool:
|
|
125
|
+
return any(a <= idx < b for a, b in spans)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class WikiLink:
|
|
130
|
+
target: str # raw target text (stripped, ``#anchor`` removed)
|
|
131
|
+
start: int
|
|
132
|
+
end: int
|
|
133
|
+
label: str | None = None
|
|
134
|
+
anchor: str | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def extract_wikilinks(text: str) -> list[WikiLink]:
|
|
138
|
+
"""Extract ``[[target]]``, ``[[target|label]]`` and ``[[target#anchor]]`` links outside code."""
|
|
139
|
+
spans = _code_mask(text)
|
|
140
|
+
links: list[WikiLink] = []
|
|
141
|
+
for m in WIKILINK_RE.finditer(text):
|
|
142
|
+
if _in_spans(m.start(), spans):
|
|
143
|
+
continue
|
|
144
|
+
inner = m.group(1).strip()
|
|
145
|
+
target, _, anchor = inner.partition("#")
|
|
146
|
+
target = target.strip()
|
|
147
|
+
if not target:
|
|
148
|
+
continue
|
|
149
|
+
label = m.group(2)[1:] if m.group(2) else None
|
|
150
|
+
links.append(WikiLink(target=target, start=m.start(), end=m.end(), label=label, anchor=anchor or None))
|
|
151
|
+
return links
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def rewrite_wikilinks(text: str, mapping: dict[str, str]) -> str:
|
|
155
|
+
"""Rewrite link targets (raw target -> new target), preserving labels and anchors."""
|
|
156
|
+
if not mapping:
|
|
157
|
+
return text
|
|
158
|
+
out: list[str] = []
|
|
159
|
+
last = 0
|
|
160
|
+
for link in extract_wikilinks(text):
|
|
161
|
+
if link.target not in mapping:
|
|
162
|
+
continue
|
|
163
|
+
new = mapping[link.target]
|
|
164
|
+
out.append(text[last : link.start])
|
|
165
|
+
inner = new + (f"#{link.anchor}" if link.anchor else "") + (f"|{link.label}" if link.label is not None else "")
|
|
166
|
+
out.append(f"[[{inner}]]")
|
|
167
|
+
last = link.end
|
|
168
|
+
out.append(text[last:])
|
|
169
|
+
return "".join(out)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Reference resolution over an in-memory catalog
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class MountCatalog:
|
|
179
|
+
"""Paths and card aliases present in one mount (used for reference resolution)."""
|
|
180
|
+
|
|
181
|
+
paths: set[str] = field(default_factory=set)
|
|
182
|
+
aliases: dict[str, str] = field(default_factory=dict) # lowercased alias -> path
|
|
183
|
+
|
|
184
|
+
def add(self, path: str, aliases: Iterable[str] = ()) -> None:
|
|
185
|
+
self.paths.add(path)
|
|
186
|
+
for a in aliases:
|
|
187
|
+
key = fold(a)
|
|
188
|
+
if key and key not in self.aliases:
|
|
189
|
+
self.aliases[key] = path
|
|
190
|
+
|
|
191
|
+
def remove(self, path: str) -> None:
|
|
192
|
+
self.paths.discard(path)
|
|
193
|
+
for key in [k for k, v in self.aliases.items() if v == path]:
|
|
194
|
+
del self.aliases[key]
|
|
195
|
+
|
|
196
|
+
def lookup(self, ref: str) -> str | None:
|
|
197
|
+
"""Resolve a slice-relative ref by exact path first, then by card alias."""
|
|
198
|
+
try:
|
|
199
|
+
path = normalize_path(ref)
|
|
200
|
+
except InvalidAddressError:
|
|
201
|
+
path = None
|
|
202
|
+
if path is not None and path in self.paths:
|
|
203
|
+
return path
|
|
204
|
+
return self.aliases.get(fold(ref)) or (self.aliases.get(fold(path)) if path else None)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class Resolution:
|
|
209
|
+
raw: str
|
|
210
|
+
mount: str | None # target mount alias (None when the ref is syntactically invalid)
|
|
211
|
+
path: str | None # canonical target path (or the intended path when unresolved)
|
|
212
|
+
resolved: bool
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def address(self) -> str | None:
|
|
216
|
+
if self.mount and self.path:
|
|
217
|
+
return format_address(self.mount, self.path)
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def resolve_ref(ref: str, src_mount: str, catalogs: dict[str, MountCatalog]) -> Resolution:
|
|
222
|
+
"""Resolve a reference written inside a card that lives in ``src_mount``."""
|
|
223
|
+
ref = (ref or "").strip()
|
|
224
|
+
if ref.startswith("@"):
|
|
225
|
+
alias, _, rest = ref.partition("/")
|
|
226
|
+
try:
|
|
227
|
+
alias = normalize_alias(alias)
|
|
228
|
+
except InvalidAddressError:
|
|
229
|
+
return Resolution(ref, None, None, False)
|
|
230
|
+
cat = catalogs.get(alias)
|
|
231
|
+
if not rest:
|
|
232
|
+
return Resolution(ref, alias, None, False)
|
|
233
|
+
if cat is not None:
|
|
234
|
+
hit = cat.lookup(rest)
|
|
235
|
+
if hit is not None:
|
|
236
|
+
return Resolution(ref, alias, hit, True)
|
|
237
|
+
try:
|
|
238
|
+
intended = normalize_path(rest)
|
|
239
|
+
except InvalidAddressError:
|
|
240
|
+
intended = None
|
|
241
|
+
return Resolution(ref, alias, intended, False)
|
|
242
|
+
cat = catalogs.get(src_mount)
|
|
243
|
+
if cat is not None:
|
|
244
|
+
hit = cat.lookup(ref)
|
|
245
|
+
if hit is not None:
|
|
246
|
+
return Resolution(ref, src_mount, hit, True)
|
|
247
|
+
try:
|
|
248
|
+
intended = normalize_path(ref)
|
|
249
|
+
except InvalidAddressError:
|
|
250
|
+
return Resolution(ref, src_mount, None, False)
|
|
251
|
+
return Resolution(ref, src_mount, intended, False)
|