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
mindslice/model/card.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""Memory-card parsing and serialization.
|
|
2
|
+
|
|
3
|
+
A card is a Markdown file with optional ``+++``-fenced TOML frontmatter::
|
|
4
|
+
|
|
5
|
+
+++
|
|
6
|
+
title = "..."
|
|
7
|
+
aliases = [...]
|
|
8
|
+
tags = [...]
|
|
9
|
+
extends = []
|
|
10
|
+
supersedes = []
|
|
11
|
+
load = "always" # optional; default "as-needed"
|
|
12
|
+
+++
|
|
13
|
+
|
|
14
|
+
One or two sentence summary paragraph.
|
|
15
|
+
|
|
16
|
+
- Atomic statement with a [[wikilink]].
|
|
17
|
+
|
|
18
|
+
Parsing is lenient (so the index can always make progress on hand-edited
|
|
19
|
+
files); strictness lives in ``validate()``. Serialization keeps untouched parts
|
|
20
|
+
of a card byte-for-byte so human formatting and TOML comments survive edits
|
|
21
|
+
that don't concern them.
|
|
22
|
+
|
|
23
|
+
Plain Markdown needs no conversion: a file without frontmatter takes its title
|
|
24
|
+
from its first ``# heading`` (or its file name) and its summary from its first
|
|
25
|
+
prose paragraph. Legacy ``---`` YAML frontmatter (Jekyll, Hugo, Obsidian) is
|
|
26
|
+
read as a flat subset: scalars, ``[a, b]`` and ``- item`` lists, ``|``/``>``
|
|
27
|
+
blocks. Nested mappings are skipped with a warning. ``write()`` keeps a YAML
|
|
28
|
+
block verbatim unless it changes the card's fields, and then writes ``+++`` TOML.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
import tomllib
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from typing import Any, Iterable
|
|
39
|
+
|
|
40
|
+
from . import tomlfmt
|
|
41
|
+
from .addressing import extract_wikilinks
|
|
42
|
+
from .text import normalize
|
|
43
|
+
from ..errors import FrontmatterError
|
|
44
|
+
|
|
45
|
+
FENCE = "+++"
|
|
46
|
+
YAML_FENCE = "---"
|
|
47
|
+
KNOWN_FIELDS = ("title", "aliases", "tags", "extends", "supersedes", "load")
|
|
48
|
+
LIST_FIELDS = ("aliases", "tags", "extends", "supersedes")
|
|
49
|
+
# ``load = "always"``: the card is read into every session at start (persona, language, standing
|
|
50
|
+
# instructions), not only when a query hits it. ``"as-needed"`` (the default) is an ordinary card.
|
|
51
|
+
LOAD_ALWAYS = "always"
|
|
52
|
+
LOAD_AS_NEEDED = "as-needed"
|
|
53
|
+
LOAD_VALUES = (LOAD_ALWAYS, LOAD_AS_NEEDED)
|
|
54
|
+
SUMMARY_PREVIEW_CHARS = 240
|
|
55
|
+
MAX_CARD_LINES = 50
|
|
56
|
+
PARSER_VERSION = "2" # in the index signature: "2" reads YAML frontmatter and plain-Markdown lead paragraphs
|
|
57
|
+
|
|
58
|
+
_BLOCK_START_RE = re.compile(r"^(?:[-*+]\s|#|```|~~~|>|\||<|\d+[.)]\s|(?:-{3,}|\*{3,}|_{3,})\s*$|\+\+\+)")
|
|
59
|
+
_BULLET_RE = re.compile(r"^[-*+]\s+")
|
|
60
|
+
_NUMBERED_RE = re.compile(r"^\d+[.)]\s+")
|
|
61
|
+
_HEADING_RE = re.compile(r"^#{1,6}\s+(.*?)\s*#*\s*$")
|
|
62
|
+
_WS_RE = re.compile(r"\s+")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sha256_bytes(data: bytes) -> str:
|
|
66
|
+
return hashlib.sha256(data).hexdigest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize_newlines(text: str) -> str:
|
|
70
|
+
text = text.lstrip("")
|
|
71
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ParsedCard:
|
|
76
|
+
"""Structured view over a card's text."""
|
|
77
|
+
|
|
78
|
+
frontmatter: dict[str, Any]
|
|
79
|
+
raw_frontmatter: str | None # text between the fences, or None when absent
|
|
80
|
+
after: str # everything after the closing fence line (or the whole text)
|
|
81
|
+
summary: str
|
|
82
|
+
body: str
|
|
83
|
+
errors: list[str] = field(default_factory=list)
|
|
84
|
+
warnings: list[str] = field(default_factory=list)
|
|
85
|
+
frontmatter_format: str | None = None # "toml" (+++), "yaml" (legacy ---) or None (plain Markdown)
|
|
86
|
+
|
|
87
|
+
# -- typed accessors -------------------------------------------------
|
|
88
|
+
@property
|
|
89
|
+
def has_frontmatter(self) -> bool:
|
|
90
|
+
return self.raw_frontmatter is not None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_card_format(self) -> bool:
|
|
94
|
+
"""True for a native card (``+++`` TOML frontmatter); False for plain Markdown or YAML."""
|
|
95
|
+
return self.frontmatter_format == "toml"
|
|
96
|
+
|
|
97
|
+
def index_parts(self) -> tuple[str, str]:
|
|
98
|
+
"""``(summary, body)`` as indexed and previewed.
|
|
99
|
+
|
|
100
|
+
A native card's summary is the paragraph right under its frontmatter. Plain Markdown and
|
|
101
|
+
YAML-fronted notes usually open with a heading, so their summary is their first prose
|
|
102
|
+
paragraph, taken out of the body so it is not counted twice.
|
|
103
|
+
"""
|
|
104
|
+
if self.is_card_format or self.summary or self.errors:
|
|
105
|
+
return self.summary, self.body
|
|
106
|
+
return split_lead(self.body)
|
|
107
|
+
|
|
108
|
+
def list_field(self, name: str) -> list[str]:
|
|
109
|
+
value = self.frontmatter.get(name)
|
|
110
|
+
if value is None:
|
|
111
|
+
return []
|
|
112
|
+
if isinstance(value, str):
|
|
113
|
+
return [value]
|
|
114
|
+
if isinstance(value, list):
|
|
115
|
+
return [v for v in value if isinstance(v, str)]
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def title(self) -> str:
|
|
120
|
+
value = self.frontmatter.get("title")
|
|
121
|
+
return value if isinstance(value, str) else ""
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def aliases(self) -> list[str]:
|
|
125
|
+
return self.list_field("aliases")
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def tags(self) -> list[str]:
|
|
129
|
+
return self.list_field("tags")
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def extends(self) -> list[str]:
|
|
133
|
+
return self.list_field("extends")
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def supersedes(self) -> list[str]:
|
|
137
|
+
return self.list_field("supersedes")
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def load(self) -> str:
|
|
141
|
+
"""``"always"`` or ``"as-needed"``; an invalid value counts as the default (validate() reports it)."""
|
|
142
|
+
return LOAD_ALWAYS if self.frontmatter.get("load") == LOAD_ALWAYS else LOAD_AS_NEEDED
|
|
143
|
+
|
|
144
|
+
def wikilink_targets(self) -> list[str]:
|
|
145
|
+
seen: dict[str, None] = {}
|
|
146
|
+
for link in extract_wikilinks(self.after):
|
|
147
|
+
seen.setdefault(link.target, None)
|
|
148
|
+
return list(seen)
|
|
149
|
+
|
|
150
|
+
def heading_title(self) -> str:
|
|
151
|
+
for line in self.after.split("\n"):
|
|
152
|
+
m = _HEADING_RE.match(line.strip())
|
|
153
|
+
if m and line.lstrip().startswith("# "):
|
|
154
|
+
return m.group(1).strip()
|
|
155
|
+
return ""
|
|
156
|
+
|
|
157
|
+
def display_title(self, path: str) -> str:
|
|
158
|
+
if self.title.strip():
|
|
159
|
+
return self.title.strip()
|
|
160
|
+
heading = self.heading_title()
|
|
161
|
+
if heading:
|
|
162
|
+
return heading
|
|
163
|
+
leaf = path.rsplit("/", 1)[-1]
|
|
164
|
+
return leaf.replace("-", " ").replace("_", " ").strip().capitalize() or path
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def split_frontmatter(text: str) -> tuple[str | None, str]:
|
|
168
|
+
"""Return ``(raw_frontmatter, after)``; ``raw_frontmatter`` is None when absent.
|
|
169
|
+
|
|
170
|
+
Raises ``FrontmatterError`` when an opening fence has no closing fence.
|
|
171
|
+
"""
|
|
172
|
+
text = normalize_newlines(text)
|
|
173
|
+
first_nl = text.find("\n")
|
|
174
|
+
first_line = text if first_nl < 0 else text[:first_nl]
|
|
175
|
+
if first_line.rstrip() != FENCE:
|
|
176
|
+
return None, text
|
|
177
|
+
pos = len(text) if first_nl < 0 else first_nl + 1
|
|
178
|
+
while pos < len(text):
|
|
179
|
+
nl = text.find("\n", pos)
|
|
180
|
+
line_end = len(text) if nl < 0 else nl
|
|
181
|
+
if text[pos:line_end].rstrip() == FENCE:
|
|
182
|
+
return text[first_nl + 1 : pos], ("" if nl < 0 else text[nl + 1 :])
|
|
183
|
+
if nl < 0:
|
|
184
|
+
break
|
|
185
|
+
pos = nl + 1
|
|
186
|
+
raise FrontmatterError("frontmatter opened with '+++' but never closed")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def split_summary(after: str) -> tuple[str, str]:
|
|
190
|
+
"""Split post-frontmatter text into ``(summary_paragraph, body)``."""
|
|
191
|
+
lines = after.split("\n")
|
|
192
|
+
i = 0
|
|
193
|
+
while i < len(lines) and not lines[i].strip():
|
|
194
|
+
i += 1
|
|
195
|
+
if i >= len(lines):
|
|
196
|
+
return "", ""
|
|
197
|
+
if _BLOCK_START_RE.match(lines[i].lstrip()) or lines[i].startswith((" ", "\t")):
|
|
198
|
+
return "", "\n".join(lines[i:])
|
|
199
|
+
j = i
|
|
200
|
+
while j < len(lines) and lines[j].strip():
|
|
201
|
+
j += 1
|
|
202
|
+
summary = "\n".join(line.strip() for line in lines[i:j]).strip()
|
|
203
|
+
k = j
|
|
204
|
+
while k < len(lines) and not lines[k].strip():
|
|
205
|
+
k += 1
|
|
206
|
+
return summary, "\n".join(lines[k:])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def parse_card(text: str) -> ParsedCard:
|
|
210
|
+
"""Parse card text leniently; problems are reported in ``errors`` / ``warnings``."""
|
|
211
|
+
errors: list[str] = []
|
|
212
|
+
warnings: list[str] = []
|
|
213
|
+
fmt: str | None = None
|
|
214
|
+
try:
|
|
215
|
+
raw_fm, after = split_frontmatter(text)
|
|
216
|
+
except FrontmatterError as exc:
|
|
217
|
+
errors.append(str(exc))
|
|
218
|
+
raw_fm, after = None, normalize_newlines(text)
|
|
219
|
+
frontmatter: dict[str, Any] = {}
|
|
220
|
+
if raw_fm is not None:
|
|
221
|
+
fmt = "toml"
|
|
222
|
+
try:
|
|
223
|
+
frontmatter = tomllib.loads(raw_fm)
|
|
224
|
+
except tomllib.TOMLDecodeError as exc:
|
|
225
|
+
errors.append(f"invalid TOML frontmatter: {exc}")
|
|
226
|
+
frontmatter = {}
|
|
227
|
+
errors.extend(schema_errors(frontmatter))
|
|
228
|
+
for name in LIST_FIELDS:
|
|
229
|
+
if isinstance(frontmatter.get(name), str):
|
|
230
|
+
warnings.append(f"frontmatter field {name!r} should be a list of strings")
|
|
231
|
+
elif not errors:
|
|
232
|
+
yaml = split_yaml_frontmatter(text)
|
|
233
|
+
if yaml is not None:
|
|
234
|
+
fmt = "yaml"
|
|
235
|
+
raw_fm, after, frontmatter, yaml_warnings = yaml
|
|
236
|
+
warnings.extend(yaml_warnings)
|
|
237
|
+
errors.extend(schema_errors(frontmatter))
|
|
238
|
+
summary, body = split_summary(after)
|
|
239
|
+
return ParsedCard(frontmatter, raw_fm, after, summary, body, errors, warnings, fmt)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Plain Markdown: lead paragraph and legacy YAML frontmatter
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def split_lead(body: str) -> tuple[str, str]:
|
|
248
|
+
"""``(first prose paragraph, body without it)``; the paragraph is what ``first_paragraph`` finds."""
|
|
249
|
+
lines = body.split("\n")
|
|
250
|
+
in_fence = False
|
|
251
|
+
start = None
|
|
252
|
+
for i, line in enumerate(lines):
|
|
253
|
+
stripped = line.strip()
|
|
254
|
+
if start is not None:
|
|
255
|
+
if not stripped or stripped.startswith(("```", "~~~")) or _BLOCK_START_RE.match(stripped):
|
|
256
|
+
end = i
|
|
257
|
+
break
|
|
258
|
+
continue
|
|
259
|
+
if stripped.startswith(("```", "~~~")):
|
|
260
|
+
in_fence = not in_fence
|
|
261
|
+
continue
|
|
262
|
+
if in_fence or not stripped or _BLOCK_START_RE.match(stripped) or line.startswith((" ", "\t")):
|
|
263
|
+
continue
|
|
264
|
+
start = i
|
|
265
|
+
else:
|
|
266
|
+
end = len(lines)
|
|
267
|
+
if start is None:
|
|
268
|
+
return "", body
|
|
269
|
+
lead = " ".join(line.strip() for line in lines[start:end])
|
|
270
|
+
rest = lines[:start] + lines[end:]
|
|
271
|
+
while start < len(rest) and start > 0 and not rest[start].strip() and not rest[start - 1].strip():
|
|
272
|
+
del rest[start] # collapse the blank line pair the paragraph left behind
|
|
273
|
+
return lead, "\n".join(rest).strip("\n")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_YAML_KEY_RE = re.compile(r"^([A-Za-z0-9_][\w-]*)[ \t]*:(?:[ \t]+(.*?))?[ \t]*$")
|
|
277
|
+
_YAML_BLOCK_SCALARS = ("|", ">", "|-", ">-", "|+", ">+")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def split_yaml_frontmatter(text: str) -> tuple[str, str, dict[str, Any], list[str]] | None:
|
|
281
|
+
"""``(raw, after, fields, warnings)`` for a leading ``---`` YAML block, or None when there is none.
|
|
282
|
+
|
|
283
|
+
A block counts only if it is closed (``---`` or ``...``) and every line is a ``key: value``
|
|
284
|
+
entry, a list item, an indented continuation or a comment; otherwise the ``---`` is a thematic
|
|
285
|
+
break and the text is plain Markdown.
|
|
286
|
+
"""
|
|
287
|
+
text = normalize_newlines(text)
|
|
288
|
+
lines = text.split("\n")
|
|
289
|
+
if not lines or lines[0].rstrip() != YAML_FENCE:
|
|
290
|
+
return None
|
|
291
|
+
close = next((i for i in range(1, len(lines)) if lines[i].rstrip() in (YAML_FENCE, "...")), None)
|
|
292
|
+
if close is None:
|
|
293
|
+
return None
|
|
294
|
+
parsed = parse_yaml_subset(lines[1:close])
|
|
295
|
+
if parsed is None:
|
|
296
|
+
return None
|
|
297
|
+
fields, warnings = parsed
|
|
298
|
+
raw = "".join(line + "\n" for line in lines[1:close])
|
|
299
|
+
return raw, "\n".join(lines[close + 1 :]), fields, warnings
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def parse_yaml_subset(lines: list[str]) -> tuple[dict[str, Any], list[str]] | None:
|
|
303
|
+
"""Flat YAML: ``key: scalar``, flow and block lists of scalars, block scalars. Values stay strings."""
|
|
304
|
+
fields: dict[str, Any] = {}
|
|
305
|
+
warnings: list[str] = []
|
|
306
|
+
i, n = 0, len(lines)
|
|
307
|
+
while i < n:
|
|
308
|
+
line = lines[i]
|
|
309
|
+
stripped = line.strip()
|
|
310
|
+
i += 1
|
|
311
|
+
if not stripped or stripped.startswith("#"):
|
|
312
|
+
continue
|
|
313
|
+
m = _YAML_KEY_RE.match(line)
|
|
314
|
+
if m is None:
|
|
315
|
+
return None # prose or an indented line at the top level: not a frontmatter block
|
|
316
|
+
key, rest = m.group(1), _strip_yaml_comment(m.group(2) or "")
|
|
317
|
+
value: Any
|
|
318
|
+
if rest in _YAML_BLOCK_SCALARS:
|
|
319
|
+
block: list[str] = []
|
|
320
|
+
while i < n and (not lines[i].strip() or lines[i][:1] in (" ", "\t")):
|
|
321
|
+
block.append(lines[i].strip())
|
|
322
|
+
i += 1
|
|
323
|
+
while block and not block[-1]:
|
|
324
|
+
block.pop()
|
|
325
|
+
value = "\n".join(block) if rest[0] == "|" else " ".join(b for b in block if b)
|
|
326
|
+
elif rest.startswith("["):
|
|
327
|
+
while rest.count("[") > rest.count("]") and i < n:
|
|
328
|
+
rest += " " + lines[i].strip()
|
|
329
|
+
i += 1
|
|
330
|
+
items = _yaml_flow_list(rest)
|
|
331
|
+
if items is None:
|
|
332
|
+
warnings.append(f"YAML field {key!r} is not a flat list; ignored")
|
|
333
|
+
continue
|
|
334
|
+
value = items
|
|
335
|
+
elif rest.startswith("{"):
|
|
336
|
+
warnings.append(f"YAML field {key!r} is a mapping; ignored")
|
|
337
|
+
continue
|
|
338
|
+
elif rest == "":
|
|
339
|
+
items, nested = [], False
|
|
340
|
+
while i < n:
|
|
341
|
+
s = lines[i].strip()
|
|
342
|
+
if s and not s.startswith("#") and not (lines[i][:1] in (" ", "\t") or s == "-" or s.startswith("- ")):
|
|
343
|
+
break
|
|
344
|
+
if s == "-" or s.startswith("- "):
|
|
345
|
+
items.append(_yaml_scalar(s[1:].strip()))
|
|
346
|
+
elif s and not s.startswith("#"):
|
|
347
|
+
nested = True
|
|
348
|
+
i += 1
|
|
349
|
+
if nested:
|
|
350
|
+
warnings.append(f"YAML field {key!r} is a nested mapping; ignored")
|
|
351
|
+
continue
|
|
352
|
+
if not items:
|
|
353
|
+
continue # null
|
|
354
|
+
value = [v for v in items if v is not None]
|
|
355
|
+
else:
|
|
356
|
+
while i < n and lines[i].strip() and lines[i][:1] in (" ", "\t"):
|
|
357
|
+
rest += " " + lines[i].strip() # a folded multi-line scalar
|
|
358
|
+
i += 1
|
|
359
|
+
value = _yaml_scalar(rest)
|
|
360
|
+
if value is None:
|
|
361
|
+
continue
|
|
362
|
+
if key in LIST_FIELDS and isinstance(value, str):
|
|
363
|
+
value = [v.strip() for v in value.split(",") if v.strip()] if key == "tags" else [value]
|
|
364
|
+
fields[key] = value
|
|
365
|
+
return fields, warnings
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _strip_yaml_comment(value: str) -> str:
|
|
369
|
+
if value[:1] in ("'", '"'):
|
|
370
|
+
quote = value[0]
|
|
371
|
+
end = value.find(quote, 1)
|
|
372
|
+
while quote == "'" and end != -1 and value[end + 1 : end + 2] == "'":
|
|
373
|
+
end = value.find(quote, end + 2)
|
|
374
|
+
while quote == '"' and end != -1 and value[end - 1] == "\\":
|
|
375
|
+
end = value.find(quote, end + 1)
|
|
376
|
+
if end != -1:
|
|
377
|
+
tail = value[end + 1 :]
|
|
378
|
+
return value[: end + 1] if tail.strip().startswith("#") or not tail.strip() else value
|
|
379
|
+
return value
|
|
380
|
+
hash_at = value.find(" #")
|
|
381
|
+
return value[:hash_at].rstrip() if hash_at != -1 else value
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _yaml_scalar(value: str) -> str | None:
|
|
385
|
+
value = _strip_yaml_comment(value.strip())
|
|
386
|
+
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
387
|
+
try:
|
|
388
|
+
return json.loads(value)
|
|
389
|
+
except json.JSONDecodeError:
|
|
390
|
+
return value[1:-1]
|
|
391
|
+
if len(value) >= 2 and value[0] == value[-1] == "'":
|
|
392
|
+
return value[1:-1].replace("''", "'")
|
|
393
|
+
if value in ("", "~", "null", "Null", "NULL"):
|
|
394
|
+
return None
|
|
395
|
+
return value
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _yaml_flow_list(value: str) -> list[str] | None:
|
|
399
|
+
value = _strip_yaml_comment(value.strip())
|
|
400
|
+
if not (value.startswith("[") and value.endswith("]")):
|
|
401
|
+
return None
|
|
402
|
+
inner = value[1:-1]
|
|
403
|
+
if "[" in inner or "{" in inner:
|
|
404
|
+
return None
|
|
405
|
+
items: list[str] = []
|
|
406
|
+
current, quote = "", None
|
|
407
|
+
for ch in inner + ",":
|
|
408
|
+
if quote:
|
|
409
|
+
current += ch
|
|
410
|
+
if ch == quote:
|
|
411
|
+
quote = None
|
|
412
|
+
elif ch in ("'", '"') and not current.strip():
|
|
413
|
+
quote = ch
|
|
414
|
+
current += ch
|
|
415
|
+
elif ch == ",":
|
|
416
|
+
item = _yaml_scalar(current)
|
|
417
|
+
if item is not None:
|
|
418
|
+
items.append(item)
|
|
419
|
+
current = ""
|
|
420
|
+
else:
|
|
421
|
+
current += ch
|
|
422
|
+
return items
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def schema_errors(frontmatter: dict[str, Any]) -> list[str]:
|
|
426
|
+
errs: list[str] = []
|
|
427
|
+
for key, value in frontmatter.items():
|
|
428
|
+
if isinstance(value, str):
|
|
429
|
+
continue
|
|
430
|
+
if isinstance(value, list) and all(isinstance(v, str) for v in value):
|
|
431
|
+
if key == "title":
|
|
432
|
+
errs.append("frontmatter field 'title' must be a string")
|
|
433
|
+
continue
|
|
434
|
+
errs.append(f"frontmatter field {key!r} must be a string or a list of strings (got {type(value).__name__})")
|
|
435
|
+
if "load" in frontmatter and frontmatter["load"] not in LOAD_VALUES:
|
|
436
|
+
errs.append(f"frontmatter field 'load' must be {' or '.join(repr(v) for v in LOAD_VALUES)} "
|
|
437
|
+
f"(got {frontmatter['load']!r})")
|
|
438
|
+
return errs
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# Serialization
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def ordered_frontmatter(fields: dict[str, Any]) -> dict[str, Any]:
|
|
447
|
+
"""Canonical key order: known fields first (always present), then extras in original order."""
|
|
448
|
+
out: dict[str, Any] = {}
|
|
449
|
+
out["title"] = fields.get("title") or ""
|
|
450
|
+
if fields.get("load") is not None: # written only when set, so ordinary cards keep their frontmatter
|
|
451
|
+
out["load"] = fields["load"]
|
|
452
|
+
for name in LIST_FIELDS:
|
|
453
|
+
value = fields.get(name) or []
|
|
454
|
+
if isinstance(value, str):
|
|
455
|
+
value = [value]
|
|
456
|
+
out[name] = list(value)
|
|
457
|
+
for key, value in fields.items():
|
|
458
|
+
if key not in out:
|
|
459
|
+
out[key] = value
|
|
460
|
+
errs = schema_errors(out)
|
|
461
|
+
if errs:
|
|
462
|
+
raise FrontmatterError("; ".join(errs))
|
|
463
|
+
return out
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def dump_frontmatter(fields: dict[str, Any]) -> str:
|
|
467
|
+
return tomlfmt.dumps_flat(ordered_frontmatter(fields))
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def compose_after(summary: str | None, body: str | None) -> str:
|
|
471
|
+
chunks = []
|
|
472
|
+
if summary and summary.strip():
|
|
473
|
+
chunks.append(summary.strip())
|
|
474
|
+
if body and body.strip():
|
|
475
|
+
chunks.append("\n".join(line.rstrip() for line in body.strip("\n").split("\n")))
|
|
476
|
+
return "\n\n".join(chunks) + "\n" if chunks else ""
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def compose_card(fields: dict[str, Any], after: str) -> str:
|
|
480
|
+
fm = dump_frontmatter(fields)
|
|
481
|
+
after = after.lstrip("\n")
|
|
482
|
+
text = f"{FENCE}\n{fm}{FENCE}\n"
|
|
483
|
+
if after:
|
|
484
|
+
text += "\n" + after
|
|
485
|
+
if not text.endswith("\n"):
|
|
486
|
+
text += "\n"
|
|
487
|
+
return text
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def render_card(fields: dict[str, Any], summary: str | None, body: str | None) -> str:
|
|
491
|
+
return compose_card(fields, compose_after(summary, body))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ---------------------------------------------------------------------------
|
|
495
|
+
# Bullet statements (append + dedupe)
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _norm_statement(block: str) -> str:
|
|
500
|
+
first, *rest = block.split("\n")
|
|
501
|
+
first = _BULLET_RE.sub("", first.strip(), count=1)
|
|
502
|
+
return _WS_RE.sub(" ", normalize(" ".join([first] + [r.strip() for r in rest]))).strip().casefold()
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def statement_blocks(content: str | Iterable[str]) -> list[str]:
|
|
506
|
+
"""Normalize caller content into ``- ...`` bullet blocks (continuation lines kept)."""
|
|
507
|
+
items = [content] if isinstance(content, str) else [str(c) for c in content]
|
|
508
|
+
blocks: list[str] = []
|
|
509
|
+
for item in items:
|
|
510
|
+
current_item_blocks: list[str] = []
|
|
511
|
+
for line in normalize_newlines(item).split("\n"):
|
|
512
|
+
if not line.strip():
|
|
513
|
+
continue
|
|
514
|
+
if line[:1] in (" ", "\t") and current_item_blocks:
|
|
515
|
+
current_item_blocks[-1] += "\n" + line.rstrip()
|
|
516
|
+
continue
|
|
517
|
+
s = line.strip()
|
|
518
|
+
if _BULLET_RE.match(s):
|
|
519
|
+
s = "- " + _BULLET_RE.sub("", s, count=1)
|
|
520
|
+
elif not _NUMBERED_RE.match(s):
|
|
521
|
+
s = "- " + s
|
|
522
|
+
current_item_blocks.append(s)
|
|
523
|
+
blocks.extend(current_item_blocks)
|
|
524
|
+
return blocks
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def existing_statements(text: str) -> set[str]:
|
|
528
|
+
out: set[str] = set()
|
|
529
|
+
current: list[str] | None = None
|
|
530
|
+
for line in text.split("\n"):
|
|
531
|
+
if _BULLET_RE.match(line) or _NUMBERED_RE.match(line):
|
|
532
|
+
if current:
|
|
533
|
+
out.add(_norm_statement("\n".join(current)))
|
|
534
|
+
current = [line]
|
|
535
|
+
elif current is not None and line[:1] in (" ", "\t") and line.strip():
|
|
536
|
+
current.append(line)
|
|
537
|
+
else:
|
|
538
|
+
if current:
|
|
539
|
+
out.add(_norm_statement("\n".join(current)))
|
|
540
|
+
current = None
|
|
541
|
+
if current:
|
|
542
|
+
out.add(_norm_statement("\n".join(current)))
|
|
543
|
+
return out
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def append_statements(after: str, blocks: list[str]) -> tuple[str, list[str]]:
|
|
547
|
+
"""Append bullet blocks not already present; returns ``(new_after, added_blocks)``."""
|
|
548
|
+
seen = existing_statements(after)
|
|
549
|
+
added: list[str] = []
|
|
550
|
+
for block in blocks:
|
|
551
|
+
key = _norm_statement(block)
|
|
552
|
+
if key in seen:
|
|
553
|
+
continue
|
|
554
|
+
seen.add(key)
|
|
555
|
+
added.append(block)
|
|
556
|
+
if not added:
|
|
557
|
+
return after, []
|
|
558
|
+
base = after.rstrip()
|
|
559
|
+
if not base.strip():
|
|
560
|
+
return "\n" + "\n".join(added) + "\n", added
|
|
561
|
+
last_para = base.split("\n\n")[-1]
|
|
562
|
+
first_line = last_para.lstrip("\n").split("\n", 1)[0]
|
|
563
|
+
sep = "\n" if (_BULLET_RE.match(first_line) or _NUMBERED_RE.match(first_line)) else "\n\n"
|
|
564
|
+
return base + sep + "\n".join(added) + "\n", added
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
# Previews
|
|
569
|
+
# ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _strip_markup(line: str) -> str:
|
|
573
|
+
line = _BULLET_RE.sub("", line.strip(), count=1)
|
|
574
|
+
line = re.sub(r"^#{1,6}\s+", "", line)
|
|
575
|
+
return line.strip()
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def first_paragraph(text: str) -> str:
|
|
579
|
+
"""First run of prose lines, skipping headings, code fences, lists, quotes and tables."""
|
|
580
|
+
in_fence = False
|
|
581
|
+
collected: list[str] = []
|
|
582
|
+
for line in text.split("\n"):
|
|
583
|
+
stripped = line.strip()
|
|
584
|
+
if stripped.startswith(("```", "~~~")):
|
|
585
|
+
in_fence = not in_fence
|
|
586
|
+
if collected:
|
|
587
|
+
break
|
|
588
|
+
continue
|
|
589
|
+
if in_fence:
|
|
590
|
+
continue
|
|
591
|
+
if not stripped:
|
|
592
|
+
if collected:
|
|
593
|
+
break
|
|
594
|
+
continue
|
|
595
|
+
if _BLOCK_START_RE.match(stripped):
|
|
596
|
+
if collected:
|
|
597
|
+
break
|
|
598
|
+
continue
|
|
599
|
+
collected.append(stripped)
|
|
600
|
+
return " ".join(collected)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def preview_text(summary: str, body: str, limit: int = SUMMARY_PREVIEW_CHARS) -> tuple[str, bool]:
|
|
604
|
+
"""Return a whitespace-collapsed summary capped at ``limit`` chars plus a truncation flag."""
|
|
605
|
+
text = summary.strip() or first_paragraph(body)
|
|
606
|
+
if not text:
|
|
607
|
+
for line in body.split("\n"):
|
|
608
|
+
cleaned = _strip_markup(line)
|
|
609
|
+
if cleaned and not cleaned.startswith(("```", "~~~", "|", "<")):
|
|
610
|
+
text = cleaned
|
|
611
|
+
break
|
|
612
|
+
text = _WS_RE.sub(" ", text).strip()
|
|
613
|
+
return truncate(text, limit)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def truncate(text: str, limit: int) -> tuple[str, bool]:
|
|
617
|
+
if limit <= 0:
|
|
618
|
+
return "", bool(text)
|
|
619
|
+
if len(text) <= limit:
|
|
620
|
+
return text, False
|
|
621
|
+
cut = text[: limit - 1]
|
|
622
|
+
space = cut.rfind(" ")
|
|
623
|
+
if space >= limit * 0.6:
|
|
624
|
+
cut = cut[:space]
|
|
625
|
+
return cut.rstrip(" ,;:") + "…", True
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def reassemble(parsed: ParsedCard, original: str, fields: dict[str, Any] | None, after: str | None) -> str:
|
|
629
|
+
"""Rebuild a card, keeping untouched regions (frontmatter text, body) verbatim."""
|
|
630
|
+
original = normalize_newlines(original)
|
|
631
|
+
if fields is None and after is None:
|
|
632
|
+
return original if original.endswith("\n") or not original else original + "\n"
|
|
633
|
+
new_after = (parsed.after if after is None else after).lstrip("\n")
|
|
634
|
+
if fields is None and parsed.raw_frontmatter is None:
|
|
635
|
+
text = new_after
|
|
636
|
+
elif fields is None and parsed.frontmatter_format == "yaml":
|
|
637
|
+
text = f"---\n{parsed.raw_frontmatter}---\n" + ("\n" + new_after if new_after else "") # kept verbatim
|
|
638
|
+
else:
|
|
639
|
+
fm = parsed.raw_frontmatter if fields is None else dump_frontmatter(fields)
|
|
640
|
+
text = f"+++\n{fm}+++\n" + ("\n" + new_after if new_after else "")
|
|
641
|
+
return text if text.endswith("\n") or not text else text + "\n"
|