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.
Files changed (50) hide show
  1. mindslice/__init__.py +58 -0
  2. mindslice/__main__.py +5 -0
  3. mindslice/baking/__init__.py +1 -0
  4. mindslice/baking/archive.py +66 -0
  5. mindslice/baking/bake.py +299 -0
  6. mindslice/baking/bake_batch.py +537 -0
  7. mindslice/baking/slice_transfer.py +265 -0
  8. mindslice/errors.py +104 -0
  9. mindslice/interfaces/__init__.py +1 -0
  10. mindslice/interfaces/bake_review.py +230 -0
  11. mindslice/interfaces/cli.py +392 -0
  12. mindslice/interfaces/cli_addressing.py +52 -0
  13. mindslice/interfaces/doctor.py +184 -0
  14. mindslice/interfaces/mcp_server.py +536 -0
  15. mindslice/interfaces/render.py +346 -0
  16. mindslice/interfaces/staging_review.py +92 -0
  17. mindslice/model/__init__.py +1 -0
  18. mindslice/model/addressing.py +251 -0
  19. mindslice/model/card.py +641 -0
  20. mindslice/model/lineage.py +228 -0
  21. mindslice/model/text.py +19 -0
  22. mindslice/model/tomlfmt.py +129 -0
  23. mindslice/operations/__init__.py +1 -0
  24. mindslice/operations/audit.py +195 -0
  25. mindslice/operations/boot.py +183 -0
  26. mindslice/operations/impact.py +98 -0
  27. mindslice/operations/recovery.py +113 -0
  28. mindslice/operations/staging.py +641 -0
  29. mindslice/operations/status.py +137 -0
  30. mindslice/operations/validation.py +250 -0
  31. mindslice/operations/writer.py +481 -0
  32. mindslice/retrieval/__init__.py +1 -0
  33. mindslice/retrieval/index.py +634 -0
  34. mindslice/retrieval/lexical.py +215 -0
  35. mindslice/retrieval/query.py +593 -0
  36. mindslice/storage/__init__.py +1 -0
  37. mindslice/storage/card_io.py +14 -0
  38. mindslice/storage/config.py +192 -0
  39. mindslice/storage/fsutil.py +141 -0
  40. mindslice/storage/history.py +182 -0
  41. mindslice/storage/locking.py +125 -0
  42. mindslice/storage/snapshots.py +262 -0
  43. mindslice/workspace.py +385 -0
  44. mindslice-0.1.0.dist-info/METADATA +98 -0
  45. mindslice-0.1.0.dist-info/RECORD +50 -0
  46. mindslice-0.1.0.dist-info/WHEEL +5 -0
  47. mindslice-0.1.0.dist-info/entry_points.txt +2 -0
  48. mindslice-0.1.0.dist-info/licenses/LICENSE +202 -0
  49. mindslice-0.1.0.dist-info/licenses/NOTICE +22 -0
  50. 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)