notebooklm-mindmap-extractor 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.
- notebooklm_mindmap_extractor/__init__.py +7 -0
- notebooklm_mindmap_extractor/__main__.py +3 -0
- notebooklm_mindmap_extractor/builder.py +380 -0
- notebooklm_mindmap_extractor/cleaner.py +290 -0
- notebooklm_mindmap_extractor/cli.py +301 -0
- notebooklm_mindmap_extractor/extractor.py +401 -0
- notebooklm_mindmap_extractor/hierarchy.py +72 -0
- notebooklm_mindmap_extractor/models.py +17 -0
- notebooklm_mindmap_extractor-0.1.0.dist-info/METADATA +198 -0
- notebooklm_mindmap_extractor-0.1.0.dist-info/RECORD +14 -0
- notebooklm_mindmap_extractor-0.1.0.dist-info/WHEEL +5 -0
- notebooklm_mindmap_extractor-0.1.0.dist-info/entry_points.txt +2 -0
- notebooklm_mindmap_extractor-0.1.0.dist-info/licenses/LICENSE +21 -0
- notebooklm_mindmap_extractor-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""NotebookLM mindmap extraction and Markdown compilation."""
|
|
2
|
+
|
|
3
|
+
from .builder import build_handbook_from_folder, generate_flattened_outline_yaml, generate_outline_yaml
|
|
4
|
+
from .hierarchy import build_hierarchy
|
|
5
|
+
from .models import MindmapNode
|
|
6
|
+
|
|
7
|
+
__all__ = ["MindmapNode", "build_handbook_from_folder", "build_hierarchy", "generate_flattened_outline_yaml", "generate_outline_yaml"]
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Generate flattened outline YAML and compile handbooks from node hierarchies."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import re
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from .models import MindmapNode
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def sanitize_filename(name: str) -> str:
|
|
11
|
+
"""Make title safe for directory and file names across platforms."""
|
|
12
|
+
cleaned = re.sub(r'[<>:"/\\|?*]', "-", name)
|
|
13
|
+
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
|
14
|
+
cleaned = re.sub(r"-+", "-", cleaned).strip("- ")
|
|
15
|
+
return cleaned[:80] or "untitled"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def slugify(text: str) -> str:
|
|
19
|
+
"""Create a lowercase URL- and folder-friendly slug."""
|
|
20
|
+
cleaned = re.sub(r"[^\w\s-]", "", text.lower()).strip()
|
|
21
|
+
return re.sub(r"[-\s]+", "-", cleaned)[:60] or "notebook"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_outline_yaml(roots: List[MindmapNode]) -> str:
|
|
25
|
+
"""Generate doc_full_order.yml with nodeTitle and childNodes entries with order attributes."""
|
|
26
|
+
lines = [
|
|
27
|
+
"# Handbook Compilation Order (doc_full_order.yml)",
|
|
28
|
+
"# Reorder sections by changing the 'order' integer or moving items.",
|
|
29
|
+
""
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
def _walk(node: MindmapNode):
|
|
33
|
+
if node.children:
|
|
34
|
+
title = " ".join(node.title.splitlines())
|
|
35
|
+
escaped_title = title.replace('"', '\\"')
|
|
36
|
+
lines.append(f"- nodeTitle: \"{escaped_title}\"")
|
|
37
|
+
lines.append(" childNodes:")
|
|
38
|
+
for idx, child in enumerate(node.children, start=1):
|
|
39
|
+
child_title = " ".join(child.title.splitlines()).replace('"', '\\"')
|
|
40
|
+
lines.append(f" - title: \"{child_title}\"")
|
|
41
|
+
lines.append(f" order: {idx}")
|
|
42
|
+
lines.append("")
|
|
43
|
+
for child in node.children:
|
|
44
|
+
_walk(child)
|
|
45
|
+
|
|
46
|
+
for root in roots:
|
|
47
|
+
_walk(root)
|
|
48
|
+
|
|
49
|
+
return "\n".join(lines).strip() + "\n"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def generate_flattened_outline_yaml(roots: List[MindmapNode]) -> str:
|
|
53
|
+
"""Backward-compatible alias for generate_outline_yaml."""
|
|
54
|
+
return generate_outline_yaml(roots)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_outline_yaml(text: str) -> List[Dict]:
|
|
58
|
+
"""Parse outline YAML supporting nodeTitle/childNodes with order attributes, flattened, and nested formats."""
|
|
59
|
+
try:
|
|
60
|
+
import yaml
|
|
61
|
+
loaded = yaml.safe_load(text)
|
|
62
|
+
if isinstance(loaded, list):
|
|
63
|
+
return loaded
|
|
64
|
+
except ImportError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
items: List[Dict] = []
|
|
68
|
+
current_item: Optional[Dict] = None
|
|
69
|
+
current_key: Optional[str] = None
|
|
70
|
+
|
|
71
|
+
for raw_line in text.splitlines():
|
|
72
|
+
stripped = raw_line.strip()
|
|
73
|
+
if not stripped or stripped.startswith("#"):
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
indent = len(raw_line) - len(raw_line.lstrip(" "))
|
|
77
|
+
|
|
78
|
+
if raw_line.startswith("- "):
|
|
79
|
+
rest = stripped[2:].strip()
|
|
80
|
+
current_item = {}
|
|
81
|
+
items.append(current_item)
|
|
82
|
+
current_key = None
|
|
83
|
+
if ":" in rest:
|
|
84
|
+
k, v = rest.split(":", 1)
|
|
85
|
+
k = k.strip()
|
|
86
|
+
v = v.strip().strip('"\'')
|
|
87
|
+
if v:
|
|
88
|
+
current_item[k] = int(v) if (k == "level" and v.isdigit()) else v
|
|
89
|
+
else:
|
|
90
|
+
current_key = k
|
|
91
|
+
current_item[k] = []
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
if current_item is not None:
|
|
95
|
+
if stripped.startswith("- "):
|
|
96
|
+
rest = stripped[2:].strip()
|
|
97
|
+
if current_key:
|
|
98
|
+
target_list = current_item.setdefault(current_key, [])
|
|
99
|
+
if ":" in rest:
|
|
100
|
+
k, v = rest.split(":", 1)
|
|
101
|
+
k = k.strip()
|
|
102
|
+
v = v.strip().strip('"\'')
|
|
103
|
+
child_dict = {k: int(v) if (k == "order" and v.isdigit()) else v}
|
|
104
|
+
target_list.append(child_dict)
|
|
105
|
+
else:
|
|
106
|
+
target_list.append(rest.strip('"\''))
|
|
107
|
+
elif ":" in stripped:
|
|
108
|
+
k, v = stripped.split(":", 1)
|
|
109
|
+
k = k.strip()
|
|
110
|
+
v = v.strip().strip('"\'')
|
|
111
|
+
if v:
|
|
112
|
+
val = int(v) if ((k in {"level", "order"}) and v.isdigit()) else v
|
|
113
|
+
if current_key and isinstance(current_item.get(current_key), list) and current_item[current_key] and isinstance(current_item[current_key][-1], dict):
|
|
114
|
+
current_item[current_key][-1][k] = val
|
|
115
|
+
elif current_key and indent > 2:
|
|
116
|
+
if not isinstance(current_item.get(current_key), dict):
|
|
117
|
+
current_item[current_key] = {}
|
|
118
|
+
current_item[current_key][k] = val
|
|
119
|
+
else:
|
|
120
|
+
current_item[k] = val
|
|
121
|
+
current_key = None
|
|
122
|
+
else:
|
|
123
|
+
current_key = k
|
|
124
|
+
current_item[k] = []
|
|
125
|
+
|
|
126
|
+
return items
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def normalize_children_order(raw_order) -> List[str]:
|
|
130
|
+
"""Normalize childNodes supporting lists of dicts (with title and order), lists of strings, or dicts."""
|
|
131
|
+
if not raw_order:
|
|
132
|
+
return []
|
|
133
|
+
if isinstance(raw_order, list):
|
|
134
|
+
if raw_order and isinstance(raw_order[0], dict):
|
|
135
|
+
sorted_items = sorted(
|
|
136
|
+
raw_order,
|
|
137
|
+
key=lambda x: int(x.get("order", 999)) if str(x.get("order", "")).isdigit() else 999
|
|
138
|
+
)
|
|
139
|
+
return [str(x.get("title") or x.get("nodeTitle") or x.get("tiltle", "")) for x in sorted_items if (x.get("title") or x.get("nodeTitle") or x.get("tiltle"))]
|
|
140
|
+
return [str(item) for item in raw_order]
|
|
141
|
+
elif isinstance(raw_order, dict):
|
|
142
|
+
first_key = next(iter(raw_order.keys()))
|
|
143
|
+
if str(first_key).isdigit():
|
|
144
|
+
sorted_keys = sorted(raw_order.keys(), key=lambda k: int(k))
|
|
145
|
+
return [str(raw_order[k]) for k in sorted_keys]
|
|
146
|
+
first_val = next(iter(raw_order.values()))
|
|
147
|
+
if str(first_val).isdigit():
|
|
148
|
+
sorted_items = sorted(raw_order.items(), key=lambda kv: int(kv[1]))
|
|
149
|
+
return [str(k) for k, _ in sorted_items]
|
|
150
|
+
return [str(k) for k in raw_order.keys()]
|
|
151
|
+
return []
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _extract_body(raw_text: str) -> str:
|
|
155
|
+
"""Extract the node body, omitting the leading top-level heading if present."""
|
|
156
|
+
lines = raw_text.strip().splitlines()
|
|
157
|
+
if lines and lines[0].startswith("#"):
|
|
158
|
+
return "\n".join(lines[1:]).strip()
|
|
159
|
+
return raw_text.strip()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def compile_node_order_entries(entries: List[Dict], base_dir: Path) -> str:
|
|
163
|
+
"""Compile document based on nodeTitle and childNodes hierarchy."""
|
|
164
|
+
if not entries:
|
|
165
|
+
return ""
|
|
166
|
+
|
|
167
|
+
order_map: Dict[str, List[str]] = {}
|
|
168
|
+
for item in entries:
|
|
169
|
+
nt = item.get("nodeTitle")
|
|
170
|
+
if nt:
|
|
171
|
+
raw_children = (
|
|
172
|
+
item.get("childNodes")
|
|
173
|
+
or item.get("childNodesOrder")
|
|
174
|
+
or item.get("childrenNodes")
|
|
175
|
+
or item.get("childrenNode")
|
|
176
|
+
or []
|
|
177
|
+
)
|
|
178
|
+
order_map[nt] = normalize_children_order(raw_children)
|
|
179
|
+
|
|
180
|
+
root_title = entries[0].get("nodeTitle", "Overview")
|
|
181
|
+
|
|
182
|
+
def _read_content_and_path(title: str, dir_path: Path, is_root: bool = False):
|
|
183
|
+
if is_root:
|
|
184
|
+
overview = dir_path / "Overview.md"
|
|
185
|
+
content = _extract_body(overview.read_text(encoding="utf-8")) if overview.exists() else ""
|
|
186
|
+
return content, dir_path
|
|
187
|
+
|
|
188
|
+
safe = sanitize_filename(title)
|
|
189
|
+
cand_dirs = [dir_path / safe, dir_path / title]
|
|
190
|
+
for cd in cand_dirs:
|
|
191
|
+
if cd.is_dir():
|
|
192
|
+
ov = cd / "Overview.md"
|
|
193
|
+
content = _extract_body(ov.read_text(encoding="utf-8")) if ov.exists() else ""
|
|
194
|
+
return content, cd
|
|
195
|
+
|
|
196
|
+
cand_files = [dir_path / f"{safe}.md", dir_path / f"{title}.md"]
|
|
197
|
+
for cf in cand_files:
|
|
198
|
+
if cf.is_file():
|
|
199
|
+
return _extract_body(cf.read_text(encoding="utf-8")), dir_path
|
|
200
|
+
|
|
201
|
+
return "", dir_path
|
|
202
|
+
|
|
203
|
+
def _walk_compile(title: str, current_dir: Path, level: int, is_root: bool = False) -> List[str]:
|
|
204
|
+
content, node_dir = _read_content_and_path(title, current_dir, is_root=is_root)
|
|
205
|
+
heading = "#" * min(level, 6)
|
|
206
|
+
body = f"\n\n{content}\n" if content else "\n"
|
|
207
|
+
sections = [f"\n{heading} {title}{body}"]
|
|
208
|
+
|
|
209
|
+
children = order_map.get(title, [])
|
|
210
|
+
for child_title in children:
|
|
211
|
+
sections.extend(_walk_compile(child_title, node_dir, level + 1, is_root=False))
|
|
212
|
+
return sections
|
|
213
|
+
|
|
214
|
+
sections = _walk_compile(root_title, base_dir, level=1, is_root=True)
|
|
215
|
+
return "".join(sections)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def compile_flattened_entries(entries: List[Dict], base_dir: Path) -> str:
|
|
219
|
+
"""Compile flattened outline entries sequentially into a single Markdown string."""
|
|
220
|
+
sections = []
|
|
221
|
+
for item in entries:
|
|
222
|
+
title = item.get("title", "Untitled")
|
|
223
|
+
file_rel = item.get("file", "")
|
|
224
|
+
level = item.get("level", 1)
|
|
225
|
+
content = ""
|
|
226
|
+
if file_rel:
|
|
227
|
+
target_file = base_dir / file_rel
|
|
228
|
+
if target_file.exists():
|
|
229
|
+
content = _extract_body(target_file.read_text(encoding="utf-8"))
|
|
230
|
+
else:
|
|
231
|
+
print(f"[-] Warning: Outline file not found: {target_file}")
|
|
232
|
+
|
|
233
|
+
heading = "#" * min(level, 6)
|
|
234
|
+
body = f"\n\n{content}\n" if content else "\n"
|
|
235
|
+
sections.append(f"\n{heading} {title}{body}")
|
|
236
|
+
|
|
237
|
+
# If entry has nested children (backward compatibility with nested YAML)
|
|
238
|
+
if item.get("children"):
|
|
239
|
+
nested_compiled = compile_nested_entries(item["children"], base_dir, level + 1)
|
|
240
|
+
if nested_compiled:
|
|
241
|
+
sections.append(nested_compiled)
|
|
242
|
+
|
|
243
|
+
return "".join(sections)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def compile_nested_entries(entries: List[Dict], base_dir: Path, level: int = 1) -> str:
|
|
247
|
+
"""Fallback compiler for nested outlines."""
|
|
248
|
+
sections = []
|
|
249
|
+
for item in entries:
|
|
250
|
+
title = item.get("title", "Untitled")
|
|
251
|
+
file_rel = item.get("file", "")
|
|
252
|
+
item_level = item.get("level", level)
|
|
253
|
+
content = ""
|
|
254
|
+
if file_rel:
|
|
255
|
+
target_file = base_dir / file_rel
|
|
256
|
+
if target_file.exists():
|
|
257
|
+
content = _extract_body(target_file.read_text(encoding="utf-8"))
|
|
258
|
+
heading = "#" * min(item_level, 6)
|
|
259
|
+
body = f"\n\n{content}\n" if content else "\n"
|
|
260
|
+
sections.append(f"\n{heading} {title}{body}")
|
|
261
|
+
if item.get("children"):
|
|
262
|
+
sections.append(compile_nested_entries(item["children"], base_dir, item_level + 1))
|
|
263
|
+
return "".join(sections)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def build_from_folder_scan(folder: Path) -> str:
|
|
267
|
+
"""Compile handbook by discovering and scanning files and directories when no outline exists."""
|
|
268
|
+
sections = []
|
|
269
|
+
overview = folder / "Overview.md"
|
|
270
|
+
root_title = "Overview"
|
|
271
|
+
if overview.exists():
|
|
272
|
+
raw = overview.read_text(encoding="utf-8")
|
|
273
|
+
lines = raw.strip().splitlines()
|
|
274
|
+
if lines and lines[0].startswith("# "):
|
|
275
|
+
root_title = lines[0][2:].strip()
|
|
276
|
+
body = _extract_body(raw)
|
|
277
|
+
sections.append(f"\n# {root_title}\n\n{body}\n" if body else f"\n# {root_title}\n")
|
|
278
|
+
|
|
279
|
+
subdirs = sorted([d for d in folder.iterdir() if d.is_dir() and not d.name.startswith(".")], key=lambda d: d.name)
|
|
280
|
+
for subdir in subdirs:
|
|
281
|
+
sub_overview = subdir / "Overview.md"
|
|
282
|
+
sub_title = subdir.name
|
|
283
|
+
if sub_overview.exists():
|
|
284
|
+
raw = sub_overview.read_text(encoding="utf-8")
|
|
285
|
+
lines = raw.strip().splitlines()
|
|
286
|
+
if lines and lines[0].startswith("# "):
|
|
287
|
+
sub_title = lines[0][2:].strip()
|
|
288
|
+
body = _extract_body(raw)
|
|
289
|
+
sections.append(f"\n## {sub_title}\n\n{body}\n" if body else f"\n## {sub_title}\n")
|
|
290
|
+
else:
|
|
291
|
+
sections.append(f"\n## {sub_title}\n")
|
|
292
|
+
|
|
293
|
+
files = sorted(
|
|
294
|
+
[f for f in subdir.iterdir() if f.is_file() and f.suffix == ".md" and f.name != "Overview.md"],
|
|
295
|
+
key=lambda f: f.name,
|
|
296
|
+
)
|
|
297
|
+
for f in files:
|
|
298
|
+
raw = f.read_text(encoding="utf-8")
|
|
299
|
+
lines = raw.strip().splitlines()
|
|
300
|
+
file_title = f.stem
|
|
301
|
+
if lines and lines[0].startswith("# "):
|
|
302
|
+
file_title = lines[0][2:].strip()
|
|
303
|
+
body = _extract_body(raw)
|
|
304
|
+
sections.append(f"\n### {file_title}\n\n{body}\n" if body else f"\n### {file_title}\n")
|
|
305
|
+
|
|
306
|
+
loose_files = sorted(
|
|
307
|
+
[
|
|
308
|
+
f for f in folder.iterdir()
|
|
309
|
+
if f.is_file() and f.suffix == ".md"
|
|
310
|
+
and f.name != "Overview.md"
|
|
311
|
+
and not f.name.endswith("-full.md")
|
|
312
|
+
and not f.name.endswith("-full-reference.md")
|
|
313
|
+
],
|
|
314
|
+
key=lambda f: f.name,
|
|
315
|
+
)
|
|
316
|
+
for f in loose_files:
|
|
317
|
+
raw = f.read_text(encoding="utf-8")
|
|
318
|
+
lines = raw.strip().splitlines()
|
|
319
|
+
file_title = f.stem
|
|
320
|
+
if lines and lines[0].startswith("# "):
|
|
321
|
+
file_title = lines[0][2:].strip()
|
|
322
|
+
body = _extract_body(raw)
|
|
323
|
+
sections.append(f"\n## {file_title}\n\n{body}\n" if body else f"\n## {file_title}\n")
|
|
324
|
+
|
|
325
|
+
return "".join(sections)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def build_handbook_from_folder(
|
|
329
|
+
folder: Path, output_file: Optional[Path] = None, outline_path: Optional[Path] = None
|
|
330
|
+
) -> Path:
|
|
331
|
+
"""Compile full handbook from a run folder using its outline.yml or custom outline."""
|
|
332
|
+
folder = Path(folder)
|
|
333
|
+
if not folder.exists():
|
|
334
|
+
resolved = folder.resolve()
|
|
335
|
+
if resolved.exists():
|
|
336
|
+
folder = resolved
|
|
337
|
+
else:
|
|
338
|
+
raise FileNotFoundError(f"Handbook folder not found: {folder}")
|
|
339
|
+
else:
|
|
340
|
+
folder = folder.resolve()
|
|
341
|
+
|
|
342
|
+
if outline_path is not None:
|
|
343
|
+
custom_outline = Path(outline_path)
|
|
344
|
+
if not custom_outline.is_absolute() and not custom_outline.exists():
|
|
345
|
+
if (folder / custom_outline).exists():
|
|
346
|
+
custom_outline = folder / custom_outline
|
|
347
|
+
if not custom_outline.exists():
|
|
348
|
+
raise FileNotFoundError(f"Outline file not found: {outline_path}")
|
|
349
|
+
target_outline = custom_outline
|
|
350
|
+
else:
|
|
351
|
+
candidates = [
|
|
352
|
+
folder / "doc_full_order.yml",
|
|
353
|
+
folder / "doc_full_order.yaml",
|
|
354
|
+
folder / "outline.yml",
|
|
355
|
+
folder / "outline.yaml",
|
|
356
|
+
]
|
|
357
|
+
target_outline = next((p for p in candidates if p.exists()), candidates[0])
|
|
358
|
+
|
|
359
|
+
if target_outline.exists():
|
|
360
|
+
print(f"[*] Compiling handbook using outline: {target_outline}")
|
|
361
|
+
entries = parse_outline_yaml(target_outline.read_text(encoding="utf-8"))
|
|
362
|
+
if entries and any("nodeTitle" in item for item in entries):
|
|
363
|
+
document = compile_node_order_entries(entries, folder)
|
|
364
|
+
else:
|
|
365
|
+
document = compile_flattened_entries(entries, folder)
|
|
366
|
+
else:
|
|
367
|
+
print(f"[*] No outline found; compiling sequentially from folder: {folder}")
|
|
368
|
+
document = build_from_folder_scan(folder)
|
|
369
|
+
|
|
370
|
+
if output_file is None:
|
|
371
|
+
# Determine [notebook-name]-full-reference.md
|
|
372
|
+
folder_name = folder.name
|
|
373
|
+
# Strip timestamp suffix like _20261027_174915 or -20261027_174915
|
|
374
|
+
base_name = re.sub(r"[-_]\d{8}[-_]\d{6}$", "", folder_name)
|
|
375
|
+
output_file = folder / f"{base_name}-full-reference.md"
|
|
376
|
+
|
|
377
|
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
378
|
+
output_file.write_text(document.strip() + "\n", encoding="utf-8")
|
|
379
|
+
print(f"[+] Full handbook compiled successfully: {output_file}")
|
|
380
|
+
return output_file
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Clean text extracted from NotebookLM: citations, UI buttons, tables, diagrams, headings."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
BOX_CHARS = set("─│┌┐└┘├┤┬┴┼▼▲◄►╔╗╚╝╠╣╦╩╬═║╭╮╯╰╱╲╳")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _is_diagram_element(line: str) -> bool:
|
|
10
|
+
if any(c in BOX_CHARS for c in line):
|
|
11
|
+
return True
|
|
12
|
+
stripped = line.strip()
|
|
13
|
+
if not stripped:
|
|
14
|
+
return False
|
|
15
|
+
if re.match(r"^\d+\.\s+[A-Z]", stripped):
|
|
16
|
+
return False
|
|
17
|
+
if stripped.startswith("#"):
|
|
18
|
+
return False
|
|
19
|
+
if ("Client" in stripped or "App" in stripped) and len(stripped) < 80:
|
|
20
|
+
return True
|
|
21
|
+
if re.search(r"\w+\s{3,}\w+", line):
|
|
22
|
+
return True
|
|
23
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
24
|
+
return True
|
|
25
|
+
if line.startswith(" ") and len(stripped) < 80:
|
|
26
|
+
return True
|
|
27
|
+
if any(sym in line for sym in ["-->", "---", "•", "·", "▼", "▲", "???"]):
|
|
28
|
+
return True
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _has_box(line: str) -> bool:
|
|
33
|
+
return any(c in BOX_CHARS for c in line)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_label(line: str) -> bool:
|
|
37
|
+
"""A short plain line such as 'Smart Router Gateway' that can sit between diagram rows."""
|
|
38
|
+
stripped = line.strip()
|
|
39
|
+
if not stripped or len(stripped) > 60 or stripped.endswith((".", ":", "?", "!")):
|
|
40
|
+
return False
|
|
41
|
+
return not stripped.startswith(("#", "* ", "- ", "|")) and not re.match(r"^\d+\.\s", stripped)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def wrap_text_diagrams(text: str) -> str:
|
|
45
|
+
"""Wrap ASCII and Unicode box-drawing blocks in fenced code blocks for markdown preview.
|
|
46
|
+
|
|
47
|
+
A diagram runs from its first box-drawing line to its last, including short label lines (up to
|
|
48
|
+
three lines apart) between rows. Existing fenced blocks are left untouched.
|
|
49
|
+
"""
|
|
50
|
+
lines = text.splitlines()
|
|
51
|
+
result = []
|
|
52
|
+
in_fence = False
|
|
53
|
+
i = 0
|
|
54
|
+
|
|
55
|
+
while i < len(lines):
|
|
56
|
+
line = lines[i]
|
|
57
|
+
if line.lstrip().startswith("```"):
|
|
58
|
+
in_fence = not in_fence
|
|
59
|
+
result.append(line)
|
|
60
|
+
i += 1
|
|
61
|
+
continue
|
|
62
|
+
if in_fence or not _has_box(line):
|
|
63
|
+
result.append(line)
|
|
64
|
+
i += 1
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
lead = []
|
|
68
|
+
while (
|
|
69
|
+
len(lead) < 2
|
|
70
|
+
and result
|
|
71
|
+
and result[-1].strip()
|
|
72
|
+
and not result[-1].lstrip().startswith("```")
|
|
73
|
+
and _is_diagram_element(result[-1])
|
|
74
|
+
):
|
|
75
|
+
lead.insert(0, result.pop())
|
|
76
|
+
|
|
77
|
+
end = i
|
|
78
|
+
k = i + 1
|
|
79
|
+
while k < len(lines) and k - end <= 3:
|
|
80
|
+
cur = lines[k]
|
|
81
|
+
if _has_box(cur):
|
|
82
|
+
end = k
|
|
83
|
+
elif cur.strip() and not (_is_diagram_element(cur) or _is_label(cur)):
|
|
84
|
+
break
|
|
85
|
+
k += 1
|
|
86
|
+
|
|
87
|
+
stop = end + 1
|
|
88
|
+
while (
|
|
89
|
+
stop < len(lines)
|
|
90
|
+
and lines[stop].strip()
|
|
91
|
+
and not lines[stop].lstrip().startswith("```")
|
|
92
|
+
and _is_diagram_element(lines[stop])
|
|
93
|
+
):
|
|
94
|
+
stop += 1
|
|
95
|
+
|
|
96
|
+
result.append("```text")
|
|
97
|
+
result.extend(lead + lines[i:stop])
|
|
98
|
+
result.append("```")
|
|
99
|
+
i = stop
|
|
100
|
+
|
|
101
|
+
return "\n".join(result)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def stitch_stray_punctuation(text: str) -> str:
|
|
105
|
+
"""Stitch stray punctuation lines back to the preceding sentence."""
|
|
106
|
+
pattern = r'([^\n])(?:[^\S\n]*\n)+(?:[^\S\n]*)([\.\,\:\;\?\!])(?:\s*([^\n]*))?'
|
|
107
|
+
|
|
108
|
+
def _repl(m):
|
|
109
|
+
prev = m.group(1)
|
|
110
|
+
punct = m.group(2)
|
|
111
|
+
rest = m.group(3)
|
|
112
|
+
if rest:
|
|
113
|
+
return f'{prev}{punct} {rest}'
|
|
114
|
+
return f'{prev}{punct}'
|
|
115
|
+
|
|
116
|
+
return re.sub(pattern, _repl, text)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def convert_tab_tables(text: str) -> str:
|
|
120
|
+
"""Convert a page table (innerText: cells split by a tab-only line, rows by a blank line) into a GFM table."""
|
|
121
|
+
lines = text.split("\n")
|
|
122
|
+
is_tab = lambda l: "\t" in l and not l.strip()
|
|
123
|
+
out = []
|
|
124
|
+
i = 0
|
|
125
|
+
while i < len(lines):
|
|
126
|
+
if not (lines[i].strip() and i + 1 < len(lines) and is_tab(lines[i + 1])):
|
|
127
|
+
out.append(lines[i])
|
|
128
|
+
i += 1
|
|
129
|
+
continue
|
|
130
|
+
rows = []
|
|
131
|
+
start = i
|
|
132
|
+
while True:
|
|
133
|
+
cells = [lines[i].strip()]
|
|
134
|
+
i += 1
|
|
135
|
+
while i + 1 < len(lines) and is_tab(lines[i]):
|
|
136
|
+
cells.append(lines[i + 1].strip())
|
|
137
|
+
i += 2
|
|
138
|
+
rows.append(cells)
|
|
139
|
+
k = i
|
|
140
|
+
while k < len(lines) and not lines[k].strip():
|
|
141
|
+
k += 1
|
|
142
|
+
if not (k + 1 < len(lines) and lines[k].strip() and is_tab(lines[k + 1])):
|
|
143
|
+
break
|
|
144
|
+
i = k
|
|
145
|
+
width = max(len(r) for r in rows)
|
|
146
|
+
if len(rows) < 2 or width < 2:
|
|
147
|
+
out.extend(lines[start:i])
|
|
148
|
+
continue
|
|
149
|
+
padded = [r + [""] * (width - len(r)) for r in rows]
|
|
150
|
+
out.append("| " + " | ".join(padded[0]) + " |")
|
|
151
|
+
out.append("| " + " | ".join(["---"] * width) + " |")
|
|
152
|
+
out.extend("| " + " | ".join(r) + " |" for r in padded[1:])
|
|
153
|
+
return "\n".join(out)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def clean_extracted_text(text: str) -> str:
|
|
157
|
+
"""Clean citations, UI action buttons, and follow-up prompts from extracted text."""
|
|
158
|
+
if not text:
|
|
159
|
+
return ""
|
|
160
|
+
# 0. Page chrome in innerText: icon names (Material icon ligatures) and the "Thoughts" header
|
|
161
|
+
text = re.sub(r'(?m)^[ \t]*(?:more_horiz|expand_more)[ \t]*\r?\n', '', text)
|
|
162
|
+
text = re.sub(r'\A\s*Thoughts[ \t]*\r?\n', '', text)
|
|
163
|
+
# 1. Clean citation numbers before punctuation: "word\n1\n\n.\n" -> "word.\n"
|
|
164
|
+
text = re.sub(r'([^\n])\s*\n+(?:\s*\d+\s*\n+)+(\s*[\.\,\:\;\?\!])', r'\1\2', text)
|
|
165
|
+
# 2. Clean citation numbers at line/paragraph ends
|
|
166
|
+
text = re.sub(r'([^\n])\s*\n+(?:\s*\d+\s*\n+)+(?=\n|$)', r'\1\n', text)
|
|
167
|
+
# 3. Stitch any stray punctuation lines back to preceding sentences
|
|
168
|
+
text = stitch_stray_punctuation(text)
|
|
169
|
+
# 4. Clean line-by-line UI buttons and trailing prompt safely without greedy multiline match
|
|
170
|
+
lines = text.splitlines()
|
|
171
|
+
ui_patterns = [
|
|
172
|
+
re.compile(r'^\s*(?:save to note|save note)\s*$', re.IGNORECASE),
|
|
173
|
+
re.compile(r'^\s*(?:copy|copy model response to clipboard|copy to clipboard|copied!?)\s*$', re.IGNORECASE),
|
|
174
|
+
re.compile(r'^\s*thumbs (?:up|down)\s*$', re.IGNORECASE),
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
cutoff_index = len(lines)
|
|
178
|
+
for idx in range(len(lines) - 1, max(-1, len(lines) - 25), -1):
|
|
179
|
+
line = lines[idx].strip()
|
|
180
|
+
if re.search(
|
|
181
|
+
r'^[^\w\s]*\s*(?:would you like to|would you like me to|do you want to|should we explore)\b',
|
|
182
|
+
line,
|
|
183
|
+
re.IGNORECASE,
|
|
184
|
+
):
|
|
185
|
+
cutoff_index = idx
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
trimmed = lines[:cutoff_index]
|
|
189
|
+
cleaned = [l for l in trimmed if not any(pat.match(l) for pat in ui_patterns) and not l.strip().isdigit()]
|
|
190
|
+
text = '\n'.join(cleaned).strip()
|
|
191
|
+
|
|
192
|
+
# 5. Convert broken tab-delimited tables into clean GFM Markdown tables
|
|
193
|
+
text = convert_tab_tables(text)
|
|
194
|
+
|
|
195
|
+
# 6. Wrap ASCII/Unicode diagrams in ```text blocks
|
|
196
|
+
text = wrap_text_diagrams(text)
|
|
197
|
+
|
|
198
|
+
# 7. Turn body headings into numbered sections and fence shell commands
|
|
199
|
+
text = convert_body_headings(text)
|
|
200
|
+
|
|
201
|
+
# 8. Each page block (paragraph, heading) is its own line: separate them so Markdown does not merge them
|
|
202
|
+
text = separate_blocks(text)
|
|
203
|
+
|
|
204
|
+
return re.sub(r'\n{3,}', '\n\n', text).strip()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def separate_blocks(text: str) -> str:
|
|
211
|
+
"""Put a blank line between consecutive text lines, except inside fences, bullet lists and tables."""
|
|
212
|
+
result = []
|
|
213
|
+
in_fence = False
|
|
214
|
+
for line in text.splitlines():
|
|
215
|
+
if line.lstrip().startswith("```"):
|
|
216
|
+
in_fence = not in_fence
|
|
217
|
+
if in_fence and result and result[-1].strip():
|
|
218
|
+
result.append("")
|
|
219
|
+
result.append(line)
|
|
220
|
+
continue
|
|
221
|
+
if not in_fence and line.strip() and result and result[-1].strip() and result[-1].lstrip()[:3] != "```":
|
|
222
|
+
prev = result[-1]
|
|
223
|
+
both_bullets = prev.startswith("* ") and line.startswith("* ")
|
|
224
|
+
both_table = prev.startswith("|") and line.startswith("|")
|
|
225
|
+
if not (both_bullets or both_table):
|
|
226
|
+
result.append("")
|
|
227
|
+
result.append(line)
|
|
228
|
+
return "\n".join(result)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def convert_body_headings(text: str) -> str:
|
|
232
|
+
"""Convert body headings (##, ###, ####) into numbered sections (1., 1.1) so they do not pollute the document outline."""
|
|
233
|
+
lines = text.splitlines()
|
|
234
|
+
result = []
|
|
235
|
+
major_count = 0
|
|
236
|
+
minor_count = 0
|
|
237
|
+
in_fence = False
|
|
238
|
+
i = 0
|
|
239
|
+
|
|
240
|
+
while i < len(lines):
|
|
241
|
+
line = lines[i]
|
|
242
|
+
stripped = line.strip()
|
|
243
|
+
i += 1
|
|
244
|
+
|
|
245
|
+
if stripped.startswith("```"):
|
|
246
|
+
in_fence = not in_fence
|
|
247
|
+
result.append(line)
|
|
248
|
+
continue
|
|
249
|
+
if in_fence:
|
|
250
|
+
result.append(line)
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
m_minor = re.match(r"^(?:#{4,6})\s+(.+)$", stripped)
|
|
254
|
+
m_major = re.match(r"^(?:#{1,3})\s+(.+)$", stripped)
|
|
255
|
+
|
|
256
|
+
if re.match(r"^#\s+(?:Register|Configure|Install|Run|Set)\b", stripped):
|
|
257
|
+
# A shell comment line and the command lines that follow it up to the next blank line.
|
|
258
|
+
block = [stripped]
|
|
259
|
+
while i < len(lines) and not lines[i].strip():
|
|
260
|
+
i += 1
|
|
261
|
+
while i < len(lines) and lines[i].strip():
|
|
262
|
+
block.append(lines[i].rstrip())
|
|
263
|
+
i += 1
|
|
264
|
+
result.extend(["```bash", *block, "```"])
|
|
265
|
+
elif m_minor:
|
|
266
|
+
minor_count += 1
|
|
267
|
+
raw_title = m_minor.group(1).strip()
|
|
268
|
+
clean_title = re.sub(r"^(?:[A-Za-z]|\d+)(?:[\.\:\)]|\s+)\s*", "", raw_title).strip()
|
|
269
|
+
if not clean_title:
|
|
270
|
+
clean_title = raw_title
|
|
271
|
+
prefix = f"{major_count}.{minor_count}." if major_count > 0 else f"{minor_count}."
|
|
272
|
+
if result and result[-1].strip():
|
|
273
|
+
result.append("")
|
|
274
|
+
result.append(f"**{prefix} {clean_title}**")
|
|
275
|
+
elif m_major:
|
|
276
|
+
major_count += 1
|
|
277
|
+
minor_count = 0
|
|
278
|
+
raw_title = m_major.group(1).strip()
|
|
279
|
+
clean_title = re.sub(r"^(?:\d+)(?:[\.\:\)]|\s+)\s*", "", raw_title).strip()
|
|
280
|
+
if not clean_title:
|
|
281
|
+
clean_title = raw_title
|
|
282
|
+
if result and result[-1].strip():
|
|
283
|
+
result.append("")
|
|
284
|
+
result.append(f"**{major_count}. {clean_title}**")
|
|
285
|
+
elif re.match(r"^#\d+\s+in\s+Development\s+Priority", stripped):
|
|
286
|
+
result.append(f"* **Rank {stripped[1:]}**")
|
|
287
|
+
else:
|
|
288
|
+
result.append(line)
|
|
289
|
+
|
|
290
|
+
return "\n".join(result)
|