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.
@@ -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,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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)