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,481 @@
|
|
|
1
|
+
"""``write()``: create / replace / append / delete cards in ``overlay/`` or ``staging/``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Iterable, Sequence
|
|
7
|
+
|
|
8
|
+
from ..model.addressing import MountCatalog, format_address, normalize_alias, normalize_path, resolve_ref
|
|
9
|
+
from ..model.card import (
|
|
10
|
+
LIST_FIELDS,
|
|
11
|
+
LOAD_ALWAYS,
|
|
12
|
+
LOAD_VALUES,
|
|
13
|
+
ParsedCard,
|
|
14
|
+
append_statements,
|
|
15
|
+
compose_after,
|
|
16
|
+
normalize_newlines,
|
|
17
|
+
parse_card,
|
|
18
|
+
render_card,
|
|
19
|
+
reassemble,
|
|
20
|
+
sha256_bytes,
|
|
21
|
+
split_summary,
|
|
22
|
+
statement_blocks,
|
|
23
|
+
)
|
|
24
|
+
from ..errors import (
|
|
25
|
+
AlreadyExistsError,
|
|
26
|
+
FrontmatterError,
|
|
27
|
+
InvalidAddressError,
|
|
28
|
+
LineageCycleError,
|
|
29
|
+
InvalidArgumentsError,
|
|
30
|
+
NotFoundError,
|
|
31
|
+
PreconditionFailedError,
|
|
32
|
+
ReadOnlyMountError,
|
|
33
|
+
)
|
|
34
|
+
from ..storage.history import change, file_action
|
|
35
|
+
from ..storage.card_io import read_card_file
|
|
36
|
+
from .impact import inbound_impact, supersede_targets
|
|
37
|
+
from ..model.lineage import cycle_through
|
|
38
|
+
from ..baking.slice_transfer import rewrite_refs
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
41
|
+
from ..workspace import Workspace
|
|
42
|
+
|
|
43
|
+
OPS = ("create", "replace", "append", "delete")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def overlay_card_path(ws: "Workspace", card_id: str, strict: bool = True) -> str:
|
|
47
|
+
"""Normalize a write target to an overlay-relative path (``@overlay/`` prefix allowed).
|
|
48
|
+
|
|
49
|
+
``strict`` (for a new card) also requires a link-safe id; ``replace`` / ``append`` / ``delete``
|
|
50
|
+
accept any existing file name, such as a note a human dropped into ``overlay/``.
|
|
51
|
+
"""
|
|
52
|
+
if not isinstance(card_id, str) or not card_id.strip():
|
|
53
|
+
raise InvalidAddressError("write() needs a non-empty card id")
|
|
54
|
+
card_id = card_id.strip()
|
|
55
|
+
overlay_alias = ws.table().overlay.alias
|
|
56
|
+
if card_id.startswith("@"):
|
|
57
|
+
alias, _, rest = card_id.partition("/")
|
|
58
|
+
if normalize_alias(alias) != overlay_alias:
|
|
59
|
+
raise ReadOnlyMountError(
|
|
60
|
+
f"{alias} is a read-only base slice; runtime writes go to {overlay_alias}",
|
|
61
|
+
alias=alias,
|
|
62
|
+
next_steps=[
|
|
63
|
+
f"To change a card of {alias}, write to the same path without the alias ({rest or 'folder/card'!r}): "
|
|
64
|
+
f"replace and append save the edited copy in {overlay_alias} as its replacement, and the slice "
|
|
65
|
+
"stays as it is until the next bake."],
|
|
66
|
+
)
|
|
67
|
+
card_id = rest
|
|
68
|
+
return normalize_path(card_id, strict=strict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _as_list(name: str, value: Any) -> list[str] | None:
|
|
72
|
+
if value is None:
|
|
73
|
+
return None
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
value = [value]
|
|
76
|
+
if not isinstance(value, (list, tuple)) or not all(isinstance(v, str) for v in value):
|
|
77
|
+
raise FrontmatterError(f"{name} must be a list of strings")
|
|
78
|
+
return [v.strip() for v in value if v.strip()]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _body_text(content: Any) -> str | None:
|
|
82
|
+
if content is None:
|
|
83
|
+
return None
|
|
84
|
+
if isinstance(content, str):
|
|
85
|
+
return normalize_newlines(content)
|
|
86
|
+
if isinstance(content, (list, tuple)):
|
|
87
|
+
return "\n".join(statement_blocks(content))
|
|
88
|
+
raise TypeError("content must be a string or a list of statements")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def humanize(path: str) -> str:
|
|
92
|
+
leaf = path.rsplit("/", 1)[-1]
|
|
93
|
+
words = leaf.replace("_", " ").replace("-", " ").split()
|
|
94
|
+
return " ".join(w[:1].upper() + w[1:] for w in words) or path
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _union(existing: Iterable[str], extra: Iterable[str]) -> list[str]:
|
|
98
|
+
out = list(dict.fromkeys(existing))
|
|
99
|
+
for item in extra:
|
|
100
|
+
if item not in out:
|
|
101
|
+
out.append(item)
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _set_load(fields: dict[str, Any], load: str | None) -> None:
|
|
106
|
+
"""``load="always"`` writes the field; ``"as-needed"`` (the default) removes it."""
|
|
107
|
+
if load == LOAD_ALWAYS:
|
|
108
|
+
fields["load"] = LOAD_ALWAYS
|
|
109
|
+
elif load is not None:
|
|
110
|
+
fields.pop("load", None)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_text(
|
|
114
|
+
op: str,
|
|
115
|
+
path: str,
|
|
116
|
+
current: str | None,
|
|
117
|
+
*,
|
|
118
|
+
content: Any,
|
|
119
|
+
summary: str | None,
|
|
120
|
+
title: str | None,
|
|
121
|
+
tags: list[str] | None,
|
|
122
|
+
aliases: list[str] | None,
|
|
123
|
+
extends: list[str] | None,
|
|
124
|
+
supersedes: list[str] | None,
|
|
125
|
+
load: str | None = None,
|
|
126
|
+
) -> tuple[str | None, dict[str, Any]]:
|
|
127
|
+
"""Compute the new card text for ``op`` (None for delete); returns ``(text, info)``."""
|
|
128
|
+
info: dict[str, Any] = {}
|
|
129
|
+
lists = {"tags": tags, "aliases": aliases, "extends": extends, "supersedes": supersedes}
|
|
130
|
+
if summary is not None:
|
|
131
|
+
summary = " ".join(normalize_newlines(summary).split())
|
|
132
|
+
if op == "create":
|
|
133
|
+
fields: dict[str, Any] = {"title": title.strip() if title else humanize(path)}
|
|
134
|
+
fields.update({k: v or [] for k, v in lists.items()})
|
|
135
|
+
_set_load(fields, load)
|
|
136
|
+
return render_card(fields, summary, _body_text(content)), info
|
|
137
|
+
if op == "delete":
|
|
138
|
+
return None, info
|
|
139
|
+
assert current is not None
|
|
140
|
+
parsed = parse_card(current)
|
|
141
|
+
if op == "replace":
|
|
142
|
+
given = {k: v for k, v in lists.items() if v is not None}
|
|
143
|
+
new_fields: dict[str, Any] | None = None
|
|
144
|
+
if title is not None or given or load is not None:
|
|
145
|
+
new_fields = dict(parsed.frontmatter) if not parsed.errors else {}
|
|
146
|
+
if title is not None:
|
|
147
|
+
new_fields["title"] = title.strip()
|
|
148
|
+
elif not parsed.title:
|
|
149
|
+
new_fields["title"] = parsed.display_title(path)
|
|
150
|
+
new_fields.update(given)
|
|
151
|
+
_set_load(new_fields, load)
|
|
152
|
+
after = None
|
|
153
|
+
body = _body_text(content)
|
|
154
|
+
if summary is not None or body is not None:
|
|
155
|
+
after = compose_after(parsed.summary if summary is None else summary, parsed.body if body is None else body)
|
|
156
|
+
return reassemble(parsed, current, new_fields, after), info
|
|
157
|
+
if op == "append":
|
|
158
|
+
if content is None and not any(v for v in lists.values()) and title is None and summary is None and load is None:
|
|
159
|
+
raise ValueError("append needs content (bullet statements) or list fields to merge")
|
|
160
|
+
blocks = statement_blocks(content) if content is not None else []
|
|
161
|
+
new_after, added = append_statements(parsed.after, blocks)
|
|
162
|
+
info["added_statements"] = len(added)
|
|
163
|
+
info["duplicate_statements"] = len(blocks) - len(added)
|
|
164
|
+
if summary is not None:
|
|
165
|
+
_, body = split_summary(new_after)
|
|
166
|
+
new_after = compose_after(summary, body)
|
|
167
|
+
new_fields = None
|
|
168
|
+
if title is not None or any(v for v in lists.values()) or load is not None:
|
|
169
|
+
new_fields = dict(parsed.frontmatter) if not parsed.errors else {}
|
|
170
|
+
if title is not None:
|
|
171
|
+
new_fields["title"] = title.strip()
|
|
172
|
+
elif not parsed.title:
|
|
173
|
+
new_fields["title"] = parsed.display_title(path)
|
|
174
|
+
for name, extra in lists.items():
|
|
175
|
+
if extra:
|
|
176
|
+
new_fields[name] = _union(parsed.list_field(name), extra)
|
|
177
|
+
_set_load(new_fields, load)
|
|
178
|
+
if new_fields == parsed.frontmatter:
|
|
179
|
+
new_fields = None
|
|
180
|
+
changed_after = new_after if new_after != parsed.after else None
|
|
181
|
+
return reassemble(parsed, current, new_fields, changed_after), info
|
|
182
|
+
raise ValueError(f"unknown op {op!r} (expected one of {', '.join(OPS)})")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Cycle checks over the prospective lineage graph
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def prospective_successors(
|
|
191
|
+
index, overlay_alias: str, overrides: dict[str, ParsedCard | None]
|
|
192
|
+
) -> tuple[dict[str, list[str]], dict[str, MountCatalog]]:
|
|
193
|
+
"""Lineage successor map (``extends`` + ``supersedes``) with overlay overrides applied."""
|
|
194
|
+
cats = index.catalogs()
|
|
195
|
+
ocat = cats.setdefault(overlay_alias, MountCatalog())
|
|
196
|
+
for path, parsed in overrides.items():
|
|
197
|
+
ocat.remove(path)
|
|
198
|
+
for path, parsed in overrides.items():
|
|
199
|
+
if parsed is not None:
|
|
200
|
+
ocat.add(path, parsed.aliases)
|
|
201
|
+
refs: list[tuple[str, str, str]] = []
|
|
202
|
+
for row in index.lineage_edges():
|
|
203
|
+
if row["src_mount"] == overlay_alias and row["src_path"] in overrides:
|
|
204
|
+
continue
|
|
205
|
+
refs.append((row["src_mount"], row["src_path"], row["raw"]))
|
|
206
|
+
for path, parsed in overrides.items():
|
|
207
|
+
if parsed is not None:
|
|
208
|
+
refs += [(overlay_alias, path, raw) for raw in parsed.extends + parsed.supersedes]
|
|
209
|
+
succ: dict[str, list[str]] = defaultdict(list)
|
|
210
|
+
for src_mount, src_path, raw in refs:
|
|
211
|
+
res = resolve_ref(raw, src_mount, cats)
|
|
212
|
+
if res.resolved and res.address:
|
|
213
|
+
succ[format_address(src_mount, src_path)].append(res.address)
|
|
214
|
+
return succ, cats
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def check_cycles(index, overlay_alias: str, overrides: dict[str, ParsedCard | None], nodes: Sequence[str]) -> None:
|
|
218
|
+
succ, _ = prospective_successors(index, overlay_alias, overrides)
|
|
219
|
+
for path in nodes:
|
|
220
|
+
if overrides.get(path) is None:
|
|
221
|
+
continue
|
|
222
|
+
start = format_address(overlay_alias, path)
|
|
223
|
+
cycle = cycle_through(start, lambda n: succ.get(n, ()))
|
|
224
|
+
if cycle:
|
|
225
|
+
raise LineageCycleError(
|
|
226
|
+
f"lineage cycle via {start}: {' -> '.join(cycle)}", cycle=cycle, address=start
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def unresolved_lineage(parsed: ParsedCard, overlay_alias: str, cats: dict[str, MountCatalog]) -> list[str]:
|
|
231
|
+
out = []
|
|
232
|
+
for kind, refs in (("extends", parsed.extends), ("supersedes", parsed.supersedes)):
|
|
233
|
+
for raw in refs:
|
|
234
|
+
if not resolve_ref(raw, overlay_alias, cats).resolved:
|
|
235
|
+
out.append(f"{kind} target {raw!r} does not resolve (yet)")
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Editing a card that lives in a read-only slice (copy-on-write)
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def mounted_base(ws: "Workspace", table, path: str) -> tuple[str, str, str] | None:
|
|
245
|
+
"""``(address, text, sha256)`` of the read-only card ``read(path)`` selects when the overlay has none."""
|
|
246
|
+
for row in ws.index.cards_at_path(path):
|
|
247
|
+
mount = table.get(row["mount"])
|
|
248
|
+
if mount is None or mount.alias == table.overlay.alias:
|
|
249
|
+
continue
|
|
250
|
+
text, sha = read_card_file(mount.root / f"{path}.md")
|
|
251
|
+
if text is not None and sha is not None:
|
|
252
|
+
return format_address(mount.alias, path), text, sha
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def fork_seed(text: str, base_address: str, cats: dict[str, MountCatalog]) -> str:
|
|
257
|
+
"""A mounted card's text as it reads from ``@overlay``: its slice-relative references made qualified.
|
|
258
|
+
|
|
259
|
+
Inside a slice ``[[other]]`` means the slice's own card; copied to the overlay unchanged it would point
|
|
260
|
+
at an overlay card of that name instead.
|
|
261
|
+
"""
|
|
262
|
+
alias = base_address.partition("/")[0]
|
|
263
|
+
|
|
264
|
+
def qualify(raw: str) -> str:
|
|
265
|
+
if raw.strip().startswith("@"):
|
|
266
|
+
return raw
|
|
267
|
+
res = resolve_ref(raw, alias, cats)
|
|
268
|
+
return res.address if res.resolved and res.address else raw
|
|
269
|
+
|
|
270
|
+
return rewrite_refs(text, parse_card(text), qualify)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _superseding(text: str, path: str, base_address: str, overlay_alias: str, cats: dict[str, MountCatalog]) -> str:
|
|
274
|
+
"""``text`` with ``base_address`` among its ``supersedes``, so hits and reads of the base forward to it."""
|
|
275
|
+
parsed = parse_card(text)
|
|
276
|
+
if any(resolve_ref(raw, overlay_alias, cats).address == base_address for raw in parsed.supersedes):
|
|
277
|
+
return text
|
|
278
|
+
fields = dict(parsed.frontmatter) if not parsed.errors else {}
|
|
279
|
+
if not parsed.title:
|
|
280
|
+
fields["title"] = parsed.display_title(path)
|
|
281
|
+
fields["supersedes"] = [*parsed.supersedes, base_address]
|
|
282
|
+
return reassemble(parsed, text, fields, None)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
# write()
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def write_impact(ws: "Workspace", table, path: str, parsed: ParsedCard | None, *, stage: bool,
|
|
291
|
+
deleting: bool, dangling: bool = False) -> dict[str, Any]:
|
|
292
|
+
"""``affected_backlinks`` / ``affected_extensions`` for the cards this write retires.
|
|
293
|
+
|
|
294
|
+
Retired cards are the card itself for a delete, else the cards it ``supersedes`` (all of them, on
|
|
295
|
+
the state the write leaves, so a retry reports the same). Empty for a write that supersedes
|
|
296
|
+
nothing. Computed under the caller's ``LOCK_EX``.
|
|
297
|
+
"""
|
|
298
|
+
if not deleting and (parsed is None or not parsed.supersedes):
|
|
299
|
+
return {}
|
|
300
|
+
overlay = table.overlay
|
|
301
|
+
address = format_address(overlay.alias, path)
|
|
302
|
+
overrides: dict[str, ParsedCard | None] = {}
|
|
303
|
+
cats: dict[str, MountCatalog] | None = None
|
|
304
|
+
if stage and not dangling:
|
|
305
|
+
overrides = ws.staging.prospective_overrides(overlay.root)
|
|
306
|
+
_, cats = prospective_successors(ws.index, overlay.alias, overrides)
|
|
307
|
+
if deleting:
|
|
308
|
+
targets = [address]
|
|
309
|
+
elif cats is not None:
|
|
310
|
+
targets = supersede_targets(parsed, overlay.alias, cats)
|
|
311
|
+
else: # a live write: the index is synced, so its resolved edges are the card's
|
|
312
|
+
targets = list(dict.fromkeys(format_address(l["dst_mount"], l["dst_path"])
|
|
313
|
+
for l in ws.index.outbound(overlay.alias, path, "supersedes") if l["resolved"]))
|
|
314
|
+
if not targets:
|
|
315
|
+
return {}
|
|
316
|
+
return inbound_impact(ws.index, overlay.alias, overlay.precedence, targets, overrides=overrides,
|
|
317
|
+
cats=cats, exclude=[address], dangling=dangling)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def write(
|
|
321
|
+
ws: "Workspace",
|
|
322
|
+
id: str,
|
|
323
|
+
op: str = "create",
|
|
324
|
+
content: Any = None,
|
|
325
|
+
summary: str | None = None,
|
|
326
|
+
title: str | None = None,
|
|
327
|
+
tags: Sequence[str] | None = None,
|
|
328
|
+
aliases: Sequence[str] | None = None,
|
|
329
|
+
extends: Sequence[str] | None = None,
|
|
330
|
+
supersedes: Sequence[str] | None = None,
|
|
331
|
+
stage: bool | None = None,
|
|
332
|
+
if_match: str | None = None,
|
|
333
|
+
load: str | None = None,
|
|
334
|
+
reason: str | None = None,
|
|
335
|
+
) -> dict[str, Any]:
|
|
336
|
+
"""``stage=None`` (the default) stages a change to the always-loaded context for the user's
|
|
337
|
+
approval and writes anything else straight to ``overlay/``; ``True`` / ``False`` force either."""
|
|
338
|
+
from .boot import protection_reason # evaluate protection against the current workspace
|
|
339
|
+
|
|
340
|
+
if reason is not None:
|
|
341
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
342
|
+
raise InvalidArgumentsError("reason must be a non-empty string when supplied")
|
|
343
|
+
reason = reason.strip()
|
|
344
|
+
if op not in OPS:
|
|
345
|
+
raise ValueError(f"unknown op {op!r} (expected one of {', '.join(OPS)})")
|
|
346
|
+
if load is not None and load not in LOAD_VALUES:
|
|
347
|
+
raise ValueError(f"load must be {' or '.join(repr(v) for v in LOAD_VALUES)} (got {load!r})")
|
|
348
|
+
path = overlay_card_path(ws, id, strict=op == "create")
|
|
349
|
+
tags_l = _as_list("tags", tags)
|
|
350
|
+
aliases_l = _as_list("aliases", aliases)
|
|
351
|
+
extends_l = _as_list("extends", extends)
|
|
352
|
+
supersedes_l = _as_list("supersedes", supersedes)
|
|
353
|
+
with ws._writing() as table:
|
|
354
|
+
overlay = table.overlay
|
|
355
|
+
address = format_address(overlay.alias, path)
|
|
356
|
+
staging = ws.staging
|
|
357
|
+
approval = None # why this change needs the user's approval (it touches the always-loaded context)
|
|
358
|
+
live_text = read_card_file(overlay.root / f"{path}.md")[0]
|
|
359
|
+
staged_text = staging.prospective_state(path, overlay.root)[0]
|
|
360
|
+
# replace / append of a card that exists only in a read-only slice (after a bake, say) edits a copy:
|
|
361
|
+
# the result is saved in the overlay as the card's replacement and the slice is left as it is.
|
|
362
|
+
base = None
|
|
363
|
+
if op in ("replace", "append") and live_text is None and staged_text is None:
|
|
364
|
+
base = mounted_base(ws, table, path)
|
|
365
|
+
if stage is not False:
|
|
366
|
+
texts = [live_text, staged_text]
|
|
367
|
+
lineage = [("extends", r) for r in extends_l or []] + [("supersedes", r) for r in supersedes_l or []]
|
|
368
|
+
if base is not None:
|
|
369
|
+
texts.append(base[1])
|
|
370
|
+
lineage.append(("supersedes", base[0]))
|
|
371
|
+
approval = protection_reason(ws, table, path, texts, load, lineage)
|
|
372
|
+
if stage is None:
|
|
373
|
+
stage = approval is not None
|
|
374
|
+
if stage:
|
|
375
|
+
current, current_sha = staging.prospective_state(path, overlay.root)
|
|
376
|
+
else:
|
|
377
|
+
current, current_sha = read_card_file(overlay.root / f"{path}.md")
|
|
378
|
+
result: dict[str, Any] = {
|
|
379
|
+
"address": address,
|
|
380
|
+
"id": path,
|
|
381
|
+
"op": op,
|
|
382
|
+
"staged": bool(stage),
|
|
383
|
+
"changed": False,
|
|
384
|
+
"previous_sha256": current_sha,
|
|
385
|
+
"warnings": [],
|
|
386
|
+
}
|
|
387
|
+
if reason is not None:
|
|
388
|
+
result["reason"] = reason
|
|
389
|
+
match_sha = base[2] if base is not None else current_sha # read(path) returned the base's sha256
|
|
390
|
+
if if_match is not None and if_match != match_sha:
|
|
391
|
+
raise PreconditionFailedError(
|
|
392
|
+
f"{address} changed since it was read (if_match={if_match[:12]}…, current="
|
|
393
|
+
f"{match_sha[:12] + '…' if match_sha else 'absent'})",
|
|
394
|
+
address=address,
|
|
395
|
+
expected=if_match,
|
|
396
|
+
current=match_sha,
|
|
397
|
+
)
|
|
398
|
+
if op == "create" and current is not None:
|
|
399
|
+
new_text, _ = build_text(
|
|
400
|
+
op, path, None, content=content, summary=summary, title=title, tags=tags_l,
|
|
401
|
+
aliases=aliases_l, extends=extends_l, supersedes=supersedes_l, load=load,
|
|
402
|
+
)
|
|
403
|
+
if new_text == normalize_newlines(current):
|
|
404
|
+
result["sha256"] = current_sha
|
|
405
|
+
result.update(write_impact(ws, table, path, parse_card(current), stage=stage, deleting=False))
|
|
406
|
+
return result # idempotent retry
|
|
407
|
+
raise AlreadyExistsError(f"{address} already exists with different content", address=address, sha256=current_sha)
|
|
408
|
+
if op in ("replace", "append") and current is None and base is None:
|
|
409
|
+
raise NotFoundError(f"{address} does not exist{' in staging or overlay' if stage else ''}", address=address)
|
|
410
|
+
if op == "delete" and current is None:
|
|
411
|
+
result["sha256"] = None
|
|
412
|
+
result["warnings"].append(f"{address} does not exist; nothing to delete")
|
|
413
|
+
result.update(write_impact(ws, table, path, None, stage=stage, deleting=True, dangling=not stage))
|
|
414
|
+
if stage:
|
|
415
|
+
staging.stage_delete(path, overlay.root, audit=ws.history) # clears stale entries, if any
|
|
416
|
+
return result
|
|
417
|
+
|
|
418
|
+
seed = fork_seed(base[1], base[0], ws.index.catalogs()) if base is not None else current
|
|
419
|
+
new_text, info = build_text(
|
|
420
|
+
op, path, seed, content=content, summary=summary, title=title, tags=tags_l,
|
|
421
|
+
aliases=aliases_l, extends=extends_l, supersedes=supersedes_l, load=load,
|
|
422
|
+
)
|
|
423
|
+
result.update(info)
|
|
424
|
+
if base is not None:
|
|
425
|
+
assert new_text is not None and seed is not None
|
|
426
|
+
if new_text == normalize_newlines(seed): # nothing to change: no copy is made
|
|
427
|
+
result["address"], result["sha256"] = base[0], base[2]
|
|
428
|
+
return result
|
|
429
|
+
new_text = _superseding(new_text, path, base[0], overlay.alias, ws.index.catalogs())
|
|
430
|
+
result["forked_from"] = base[0]
|
|
431
|
+
parsed_new: ParsedCard | None = None
|
|
432
|
+
if new_text is not None:
|
|
433
|
+
parsed_new = parse_card(new_text)
|
|
434
|
+
if parsed_new.errors:
|
|
435
|
+
raise FrontmatterError("; ".join(parsed_new.errors), address=address)
|
|
436
|
+
overrides: dict[str, ParsedCard | None] = {}
|
|
437
|
+
if stage:
|
|
438
|
+
overrides.update(staging.prospective_overrides(overlay.root))
|
|
439
|
+
overrides[path] = parsed_new
|
|
440
|
+
check_cycles(ws.index, overlay.alias, overrides, [path])
|
|
441
|
+
_, cats = prospective_successors(ws.index, overlay.alias, overrides)
|
|
442
|
+
result["warnings"] += unresolved_lineage(parsed_new, overlay.alias, cats)
|
|
443
|
+
|
|
444
|
+
if new_text is not None and current is not None and new_text == normalize_newlines(current):
|
|
445
|
+
result["sha256"] = current_sha
|
|
446
|
+
result.update(write_impact(ws, table, path, parsed_new, stage=stage, deleting=False))
|
|
447
|
+
return result
|
|
448
|
+
|
|
449
|
+
# A delete's impact is read before the card goes (its inbound references still resolve).
|
|
450
|
+
impact = write_impact(ws, table, path, None, stage=stage, deleting=True) if new_text is None else None
|
|
451
|
+
change_event = change("proposed" if stage else "applied", address=address, op=op,
|
|
452
|
+
reason=reason, before_sha256=current_sha,
|
|
453
|
+
after_sha256=sha256_bytes(new_text.encode("utf-8")) if new_text is not None else None,
|
|
454
|
+
origin="write")
|
|
455
|
+
result["change_id"] = change_event["change_id"]
|
|
456
|
+
if stage:
|
|
457
|
+
if new_text is None:
|
|
458
|
+
staging.stage_delete(path, overlay.root, requires_approval=approval, audit=ws.history, event=change_event)
|
|
459
|
+
else:
|
|
460
|
+
staging.stage_write(path, new_text, overlay.root, op, requires_approval=approval, audit=ws.history, event=change_event)
|
|
461
|
+
if approval:
|
|
462
|
+
result["requires_approval"] = approval
|
|
463
|
+
result["warnings"].append(
|
|
464
|
+
f"Staged, not applied, because {approval}. Always-loaded cards shape every session, so the user "
|
|
465
|
+
"approves changes to them: tell the user, who can review it with `mindslice diff` and approve it "
|
|
466
|
+
"with `mindslice apply` (or drop it with `mindslice discard`)."
|
|
467
|
+
)
|
|
468
|
+
else:
|
|
469
|
+
file = overlay.root / f"{path}.md"
|
|
470
|
+
ws.history.transact([file_action(file, new_text, overlay.root)], [change_event])
|
|
471
|
+
ws._sync(table)
|
|
472
|
+
result["changed"] = True
|
|
473
|
+
result["sha256"] = sha256_bytes(new_text.encode("utf-8")) if new_text is not None else None
|
|
474
|
+
if impact is None:
|
|
475
|
+
impact = write_impact(ws, table, path, parsed_new, stage=stage, deleting=False)
|
|
476
|
+
result.update(impact)
|
|
477
|
+
return result
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
__all__ = ["write", "write_impact", "build_text", "check_cycles", "prospective_successors", "OPS",
|
|
481
|
+
"LIST_FIELDS"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""SQLite indexing, lexical analysis and memory queries."""
|