docgraphical 1.0.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.
- docgraph/__init__.py +13 -0
- docgraph/cli.py +61 -0
- docgraph/config.py +66 -0
- docgraph/constants.py +13 -0
- docgraph/db.py +300 -0
- docgraph/mcp_server.py +69 -0
- docgraph/parser.py +209 -0
- docgraph/scanner.py +113 -0
- docgraph/server.py +187 -0
- docgraphical/__init__.py +13 -0
- docgraphical/cli.py +61 -0
- docgraphical/config.py +112 -0
- docgraphical/constants.py +13 -0
- docgraphical/db.py +355 -0
- docgraphical/mcp_server.py +69 -0
- docgraphical/parser.py +209 -0
- docgraphical/scanner.py +118 -0
- docgraphical/server.py +219 -0
- docgraphical-1.0.0.dist-info/METADATA +303 -0
- docgraphical-1.0.0.dist-info/RECORD +24 -0
- docgraphical-1.0.0.dist-info/WHEEL +5 -0
- docgraphical-1.0.0.dist-info/entry_points.txt +4 -0
- docgraphical-1.0.0.dist-info/licenses/LICENSE +21 -0
- docgraphical-1.0.0.dist-info/top_level.txt +2 -0
docgraph/parser.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""DocGraph Markdown Parsing & Extraction Engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class HeadingNode:
|
|
13
|
+
level: int
|
|
14
|
+
title: str
|
|
15
|
+
line_number: int
|
|
16
|
+
raw_line: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_headings(file_path: str) -> List[HeadingNode]:
|
|
20
|
+
"""Parse all headings (# ... ######) from a Markdown file, ignoring code blocks."""
|
|
21
|
+
if not os.path.exists(file_path):
|
|
22
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
23
|
+
|
|
24
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
25
|
+
lines = f.readlines()
|
|
26
|
+
|
|
27
|
+
headings: List[HeadingNode] = []
|
|
28
|
+
in_code_block = False
|
|
29
|
+
|
|
30
|
+
for idx, line in enumerate(lines, 1):
|
|
31
|
+
stripped = line.strip()
|
|
32
|
+
|
|
33
|
+
# Handle fenced code block guards
|
|
34
|
+
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
35
|
+
in_code_block = not in_code_block
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
if in_code_block:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
# Match ATX headings: # Title
|
|
42
|
+
m = re.match(r"^(#{1,6})\s+(.+)$", stripped)
|
|
43
|
+
if m:
|
|
44
|
+
level = len(m.group(1))
|
|
45
|
+
title = m.group(2).strip()
|
|
46
|
+
# Clean trailing hashes if any
|
|
47
|
+
title = re.sub(r"\s+#+$", "", title)
|
|
48
|
+
headings.append(HeadingNode(level=level, title=title, line_number=idx, raw_line=line))
|
|
49
|
+
|
|
50
|
+
return headings
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def extract_toc(file_path: str, format_type: str = "text") -> str:
|
|
54
|
+
"""Extract Table of Contents (TOC) with line number anchors.
|
|
55
|
+
|
|
56
|
+
format_type: 'text' (indented tree), 'json', or 'markdown'
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
headings = parse_headings(file_path)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
return f"Error: {e}"
|
|
62
|
+
|
|
63
|
+
if not headings:
|
|
64
|
+
return f"[DocGraph] No Markdown headings found in {os.path.basename(file_path)}"
|
|
65
|
+
|
|
66
|
+
if format_type == "json":
|
|
67
|
+
import json
|
|
68
|
+
data = [
|
|
69
|
+
{"level": h.level, "title": h.title, "line": h.line_number}
|
|
70
|
+
for h in headings
|
|
71
|
+
]
|
|
72
|
+
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
73
|
+
|
|
74
|
+
elif format_type == "markdown":
|
|
75
|
+
lines = [f"## Table of Contents: {os.path.basename(file_path)}\n"]
|
|
76
|
+
for h in headings:
|
|
77
|
+
indent = " " * (h.level - 1)
|
|
78
|
+
lines.append(f"{indent}- [{h.title}](#line-{h.line_number}) *(Line {h.line_number})*")
|
|
79
|
+
return "\n".join(lines)
|
|
80
|
+
|
|
81
|
+
else: # default text tree
|
|
82
|
+
lines = [f"=== [DocGraph TOC] {os.path.basename(file_path)} ==="]
|
|
83
|
+
for h in headings:
|
|
84
|
+
indent = " " * (h.level - 1)
|
|
85
|
+
hashes = "#" * h.level
|
|
86
|
+
lines.append(f"[Line {h.line_number:4d}] {indent}{hashes} {h.title}")
|
|
87
|
+
return "\n".join(lines)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def extract_section(
|
|
91
|
+
file_path: str,
|
|
92
|
+
target_heading: str,
|
|
93
|
+
include_subsections: bool = True
|
|
94
|
+
) -> str:
|
|
95
|
+
"""Surgically extract a specific section by heading title."""
|
|
96
|
+
if not os.path.exists(file_path):
|
|
97
|
+
return f"Error: File not found: {file_path}"
|
|
98
|
+
|
|
99
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
100
|
+
lines = f.readlines()
|
|
101
|
+
|
|
102
|
+
target_clean = target_heading.strip().lower().lstrip("#").strip()
|
|
103
|
+
matched_idx = -1
|
|
104
|
+
matched_level = -1
|
|
105
|
+
in_code_block = False
|
|
106
|
+
|
|
107
|
+
# Find the target heading line
|
|
108
|
+
for idx, line in enumerate(lines):
|
|
109
|
+
stripped = line.strip()
|
|
110
|
+
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
111
|
+
in_code_block = not in_code_block
|
|
112
|
+
continue
|
|
113
|
+
if in_code_block:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
m = re.match(r"^(#{1,6})\s+(.+)$", stripped)
|
|
117
|
+
if m:
|
|
118
|
+
level = len(m.group(1))
|
|
119
|
+
title = m.group(2).strip().lower()
|
|
120
|
+
title = re.sub(r"\s+#+$", "", title)
|
|
121
|
+
if target_clean in title or target_clean == title:
|
|
122
|
+
matched_idx = idx
|
|
123
|
+
matched_level = level
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
if matched_idx == -1:
|
|
127
|
+
return f"Error: Heading '{target_heading}' not found in {os.path.basename(file_path)}"
|
|
128
|
+
|
|
129
|
+
section_lines = [lines[matched_idx]]
|
|
130
|
+
in_code_block = False
|
|
131
|
+
|
|
132
|
+
for idx in range(matched_idx + 1, len(lines)):
|
|
133
|
+
line = lines[idx]
|
|
134
|
+
stripped = line.strip()
|
|
135
|
+
|
|
136
|
+
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
137
|
+
in_code_block = not in_code_block
|
|
138
|
+
section_lines.append(line)
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if not in_code_block:
|
|
142
|
+
m = re.match(r"^(#{1,6})\s+(.+)$", stripped)
|
|
143
|
+
if m:
|
|
144
|
+
level = len(m.group(1))
|
|
145
|
+
if include_subsections:
|
|
146
|
+
if level <= matched_level:
|
|
147
|
+
break
|
|
148
|
+
else:
|
|
149
|
+
if level <= matched_level or level > matched_level:
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
section_lines.append(line)
|
|
153
|
+
|
|
154
|
+
result_text = "".join(section_lines).strip()
|
|
155
|
+
header_banner = f"<!-- [DocGraph Section] {os.path.basename(file_path)} | Line {matched_idx + 1} -->\n"
|
|
156
|
+
return f"{header_banner}{result_text}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def search_doc(
|
|
160
|
+
path: str,
|
|
161
|
+
query: str,
|
|
162
|
+
max_results: int = 30
|
|
163
|
+
) -> str:
|
|
164
|
+
"""Search Markdown file or directory for relevant sections or lines."""
|
|
165
|
+
if not os.path.exists(path):
|
|
166
|
+
return f"Error: Path not found: {path}"
|
|
167
|
+
|
|
168
|
+
md_files: List[str] = []
|
|
169
|
+
if os.path.isfile(path):
|
|
170
|
+
md_files.append(path)
|
|
171
|
+
else:
|
|
172
|
+
for root, _, files in os.walk(path):
|
|
173
|
+
# Ignore hidden or vendor directories
|
|
174
|
+
if any(part.startswith(".") or part in ("node_modules", "dist", "build", "__pycache__") for part in root.split(os.sep)):
|
|
175
|
+
continue
|
|
176
|
+
for file in files:
|
|
177
|
+
if file.lower().endswith((".md", ".markdown", ".mdown")):
|
|
178
|
+
md_files.append(os.path.join(root, file))
|
|
179
|
+
|
|
180
|
+
if not md_files:
|
|
181
|
+
return f"No Markdown files found in {path}"
|
|
182
|
+
|
|
183
|
+
results: List[str] = []
|
|
184
|
+
query_clean = query.strip().lower()
|
|
185
|
+
|
|
186
|
+
for mf in md_files:
|
|
187
|
+
try:
|
|
188
|
+
with open(mf, "r", encoding="utf-8", errors="replace") as f:
|
|
189
|
+
lines = f.readlines()
|
|
190
|
+
|
|
191
|
+
for idx, line in enumerate(lines, 1):
|
|
192
|
+
if query_clean in line.lower():
|
|
193
|
+
rel_path = os.path.relpath(mf, path) if os.path.isdir(path) else os.path.basename(mf)
|
|
194
|
+
snippet = line.strip()
|
|
195
|
+
if len(snippet) > 120:
|
|
196
|
+
snippet = snippet[:117] + "..."
|
|
197
|
+
results.append(f"- **{rel_path}** [L{idx}]: `{snippet}`")
|
|
198
|
+
if len(results) >= max_results:
|
|
199
|
+
break
|
|
200
|
+
if len(results) >= max_results:
|
|
201
|
+
break
|
|
202
|
+
except Exception:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if not results:
|
|
206
|
+
return f"No matches found for '{query}' in {len(md_files)} Markdown file(s)."
|
|
207
|
+
|
|
208
|
+
header = f"=== [DocGraph Search] Matches for '{query}' ({len(results)} found) ===\n"
|
|
209
|
+
return header + "\n".join(results)
|
docgraph/scanner.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Documentation discovery and filesystem tree scanning."""
|
|
2
|
+
import os
|
|
3
|
+
from typing import Dict, List, Any, Optional
|
|
4
|
+
from .constants import IGNORE_DIRS, DOC_EXTS
|
|
5
|
+
from .config import load_config
|
|
6
|
+
from .parser import parse_headings
|
|
7
|
+
|
|
8
|
+
def is_doc_file(filename: str) -> bool:
|
|
9
|
+
return filename.lower().endswith(DOC_EXTS)
|
|
10
|
+
|
|
11
|
+
def scan_doc_repositories(search_roots: List[str]) -> Dict[str, str]:
|
|
12
|
+
"""Discover all folders containing markdown documents under search roots."""
|
|
13
|
+
cfg = load_config()
|
|
14
|
+
excluded = [os.path.abspath(p) for p in cfg.get("excluded_paths", [])]
|
|
15
|
+
|
|
16
|
+
repos: Dict[str, str] = {}
|
|
17
|
+
|
|
18
|
+
for root_dir in search_roots:
|
|
19
|
+
if not os.path.isdir(root_dir):
|
|
20
|
+
continue
|
|
21
|
+
abs_root = os.path.abspath(root_dir)
|
|
22
|
+
if abs_root in excluded:
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
# Add root itself
|
|
26
|
+
repos[os.path.basename(abs_root) or abs_root] = abs_root
|
|
27
|
+
|
|
28
|
+
# Scan 2 levels down for project folders
|
|
29
|
+
try:
|
|
30
|
+
for entry in os.scandir(abs_root):
|
|
31
|
+
if entry.is_dir() and entry.name not in IGNORE_DIRS and not entry.name.startswith("."):
|
|
32
|
+
sub_path = os.path.abspath(entry.path)
|
|
33
|
+
if sub_path not in excluded:
|
|
34
|
+
# Check if has any markdown files
|
|
35
|
+
has_md = False
|
|
36
|
+
try:
|
|
37
|
+
for sub_entry in os.scandir(sub_path):
|
|
38
|
+
if sub_entry.is_file() and is_doc_file(sub_entry.name):
|
|
39
|
+
has_md = True
|
|
40
|
+
break
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
if has_md:
|
|
44
|
+
repos[entry.name] = sub_path
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
return repos
|
|
49
|
+
|
|
50
|
+
def get_dir_file_tree(repo_path: str) -> Dict[str, Any]:
|
|
51
|
+
"""Generate recursive folder and markdown file tree."""
|
|
52
|
+
repo_path = os.path.abspath(repo_path)
|
|
53
|
+
if not os.path.exists(repo_path):
|
|
54
|
+
return {"name": os.path.basename(repo_path), "path": repo_path, "type": "dir", "children": []}
|
|
55
|
+
|
|
56
|
+
def build_tree(current_path: str) -> Dict[str, Any]:
|
|
57
|
+
node: Dict[str, Any] = {
|
|
58
|
+
"name": os.path.basename(current_path) or current_path,
|
|
59
|
+
"path": current_path,
|
|
60
|
+
"type": "dir",
|
|
61
|
+
"children": []
|
|
62
|
+
}
|
|
63
|
+
try:
|
|
64
|
+
entries = sorted(os.scandir(current_path), key=lambda e: (not e.is_dir(), e.name.lower()))
|
|
65
|
+
for e in entries:
|
|
66
|
+
if e.name.startswith(".") or e.name in IGNORE_DIRS:
|
|
67
|
+
continue
|
|
68
|
+
if e.is_dir():
|
|
69
|
+
child_tree = build_tree(e.path)
|
|
70
|
+
# Only include dirs that contain markdown or subdirs
|
|
71
|
+
if child_tree["children"]:
|
|
72
|
+
node["children"].append(child_tree)
|
|
73
|
+
elif e.is_file() and is_doc_file(e.name):
|
|
74
|
+
try:
|
|
75
|
+
headings = parse_headings(e.path)
|
|
76
|
+
h_count = len(headings)
|
|
77
|
+
except Exception:
|
|
78
|
+
h_count = 0
|
|
79
|
+
node["children"].append({
|
|
80
|
+
"name": e.name,
|
|
81
|
+
"path": e.path,
|
|
82
|
+
"type": "file",
|
|
83
|
+
"headings": h_count,
|
|
84
|
+
"size": e.stat().st_size
|
|
85
|
+
})
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
return node
|
|
89
|
+
|
|
90
|
+
return build_tree(repo_path)
|
|
91
|
+
|
|
92
|
+
def get_repo_doc_metrics(repo_path: str) -> Dict[str, Any]:
|
|
93
|
+
"""Compute markdown file count and heading metrics for a repository."""
|
|
94
|
+
md_files = 0
|
|
95
|
+
total_headings = 0
|
|
96
|
+
total_size = 0
|
|
97
|
+
for root, dirs, files in os.walk(repo_path):
|
|
98
|
+
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")]
|
|
99
|
+
for f in files:
|
|
100
|
+
if is_doc_file(f):
|
|
101
|
+
md_files += 1
|
|
102
|
+
full = os.path.join(root, f)
|
|
103
|
+
try:
|
|
104
|
+
total_size += os.path.getsize(full)
|
|
105
|
+
h = parse_headings(full)
|
|
106
|
+
total_headings += len(h)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
return {
|
|
110
|
+
"files": md_files,
|
|
111
|
+
"headings": total_headings,
|
|
112
|
+
"size": total_size
|
|
113
|
+
}
|
docgraph/server.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Flask web server application factory and RESTful API routing for DocGraph."""
|
|
2
|
+
import os
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
from flask import Flask, jsonify, request, Response, render_template
|
|
5
|
+
from .config import load_config, save_config, get_search_roots
|
|
6
|
+
from .scanner import scan_doc_repositories, get_dir_file_tree, get_repo_doc_metrics
|
|
7
|
+
from .parser import extract_toc, extract_section, search_doc, parse_headings
|
|
8
|
+
from .db import index_repository, fetch_graph_data, get_db_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_app(initial_paths: Optional[List[str]] = None, search_roots: Optional[List[str]] = None) -> Flask:
|
|
14
|
+
"""Create and configure the DocGraph Flask application."""
|
|
15
|
+
if initial_paths and not search_roots:
|
|
16
|
+
search_roots = initial_paths
|
|
17
|
+
|
|
18
|
+
def resolve_file(file_path: str) -> Optional[str]:
|
|
19
|
+
if not file_path:
|
|
20
|
+
return None
|
|
21
|
+
if os.path.isfile(file_path):
|
|
22
|
+
return os.path.abspath(file_path)
|
|
23
|
+
roots = get_search_roots(search_roots)
|
|
24
|
+
for r in roots:
|
|
25
|
+
cand = os.path.normpath(os.path.join(r, file_path))
|
|
26
|
+
if os.path.isfile(cand):
|
|
27
|
+
return cand
|
|
28
|
+
for dirpath, _, filenames in os.walk(r):
|
|
29
|
+
if ".git" in dirpath or "node_modules" in dirpath or ".docgraph" in dirpath:
|
|
30
|
+
continue
|
|
31
|
+
for fn in filenames:
|
|
32
|
+
full = os.path.join(dirpath, fn)
|
|
33
|
+
if fn == file_path or full.replace('\\', '/').endswith(file_path.replace('\\', '/')):
|
|
34
|
+
return full
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
38
|
+
template_dir = os.path.join(app_root, "templates")
|
|
39
|
+
static_dir = os.path.join(app_root, "static")
|
|
40
|
+
|
|
41
|
+
app = Flask(
|
|
42
|
+
"docgraph",
|
|
43
|
+
template_folder=template_dir,
|
|
44
|
+
static_folder=static_dir
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@app.after_request
|
|
48
|
+
def add_security_and_cache_headers(response: Response) -> Response:
|
|
49
|
+
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
|
50
|
+
response.headers["Pragma"] = "no-cache"
|
|
51
|
+
response.headers["Expires"] = "0"
|
|
52
|
+
return response
|
|
53
|
+
|
|
54
|
+
@app.route("/")
|
|
55
|
+
def index():
|
|
56
|
+
return render_template("index.html")
|
|
57
|
+
|
|
58
|
+
@app.route("/api/projects", methods=["GET"])
|
|
59
|
+
def get_projects():
|
|
60
|
+
roots = get_search_roots(search_roots)
|
|
61
|
+
repos = scan_doc_repositories(roots)
|
|
62
|
+
result = []
|
|
63
|
+
for name, p in repos.items():
|
|
64
|
+
metrics = get_repo_doc_metrics(p)
|
|
65
|
+
db_file = os.path.join(p, ".docgraph", "docgraph.db")
|
|
66
|
+
has_db = os.path.exists(db_file)
|
|
67
|
+
result.append({
|
|
68
|
+
"name": name,
|
|
69
|
+
"path": p,
|
|
70
|
+
"files": metrics["files"],
|
|
71
|
+
"headings": metrics["headings"],
|
|
72
|
+
"size": metrics["size"],
|
|
73
|
+
"has_db": has_db,
|
|
74
|
+
"status": "ready" if metrics["files"] > 0 else "empty"
|
|
75
|
+
})
|
|
76
|
+
return jsonify(result)
|
|
77
|
+
|
|
78
|
+
@app.route("/api/graph", methods=["GET"])
|
|
79
|
+
def get_graph():
|
|
80
|
+
target_path = request.args.get("path", "").strip()
|
|
81
|
+
if not target_path or not os.path.exists(target_path):
|
|
82
|
+
roots = get_search_roots(search_roots)
|
|
83
|
+
target_path = roots[0] if roots else os.getcwd()
|
|
84
|
+
|
|
85
|
+
data = fetch_graph_data(target_path)
|
|
86
|
+
return jsonify(data)
|
|
87
|
+
|
|
88
|
+
@app.route("/api/index", methods=["POST"])
|
|
89
|
+
def trigger_index():
|
|
90
|
+
data = request.get_json(silent=True) or {}
|
|
91
|
+
target_path = data.get("path", "").strip()
|
|
92
|
+
if not target_path or not os.path.exists(target_path):
|
|
93
|
+
return jsonify({"success": False, "error": "Invalid project path"}), 400
|
|
94
|
+
|
|
95
|
+
f_cnt, n_cnt, e_cnt = index_repository(target_path)
|
|
96
|
+
return jsonify({"success": True, "files": f_cnt, "nodes": n_cnt, "edges": e_cnt})
|
|
97
|
+
|
|
98
|
+
@app.route("/api/tree", methods=["GET"])
|
|
99
|
+
def get_tree():
|
|
100
|
+
target_path = request.args.get("path", "").strip()
|
|
101
|
+
if not target_path or not os.path.exists(target_path):
|
|
102
|
+
roots = get_search_roots(search_roots)
|
|
103
|
+
target_path = roots[0] if roots else os.getcwd()
|
|
104
|
+
tree = get_dir_file_tree(target_path)
|
|
105
|
+
return jsonify(tree)
|
|
106
|
+
|
|
107
|
+
@app.route("/api/doc/toc", methods=["GET"])
|
|
108
|
+
def get_doc_toc():
|
|
109
|
+
file_path = request.args.get("file", "").strip()
|
|
110
|
+
format_type = request.args.get("format", "json").strip()
|
|
111
|
+
real_path = resolve_file(file_path)
|
|
112
|
+
if not real_path:
|
|
113
|
+
return jsonify({"error": "File not found: " + file_path}), 404
|
|
114
|
+
file_path = real_path
|
|
115
|
+
toc_output = extract_toc(file_path, format_type=format_type)
|
|
116
|
+
if format_type == "json":
|
|
117
|
+
import json
|
|
118
|
+
try:
|
|
119
|
+
return jsonify(json.loads(toc_output))
|
|
120
|
+
except Exception:
|
|
121
|
+
return jsonify([])
|
|
122
|
+
return Response(toc_output, mimetype="text/plain")
|
|
123
|
+
|
|
124
|
+
@app.route("/api/doc/section", methods=["GET"])
|
|
125
|
+
def get_doc_section():
|
|
126
|
+
file_path = request.args.get("file", "").strip()
|
|
127
|
+
heading = request.args.get("heading", "").strip()
|
|
128
|
+
include_sub = request.args.get("sub", "1") == "1"
|
|
129
|
+
real_path = resolve_file(file_path)
|
|
130
|
+
if not real_path:
|
|
131
|
+
return jsonify({"error": "File not found: " + file_path}), 404
|
|
132
|
+
file_path = real_path
|
|
133
|
+
if not heading:
|
|
134
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
135
|
+
content = f.read()
|
|
136
|
+
return jsonify({"heading": "Full Document", "content": content, "sliced": False})
|
|
137
|
+
|
|
138
|
+
sec = extract_section(file_path, heading, include_subsections=include_sub)
|
|
139
|
+
return jsonify({"heading": heading, "content": sec, "sliced": True})
|
|
140
|
+
|
|
141
|
+
@app.route("/api/doc/search", methods=["GET"])
|
|
142
|
+
def search_documents():
|
|
143
|
+
target_path = request.args.get("path", "").strip()
|
|
144
|
+
query = request.args.get("q", "").strip()
|
|
145
|
+
limit = int(request.args.get("limit", 30))
|
|
146
|
+
if not target_path or not os.path.exists(target_path):
|
|
147
|
+
roots = get_search_roots(search_roots)
|
|
148
|
+
target_path = roots[0] if roots else os.getcwd()
|
|
149
|
+
if not query:
|
|
150
|
+
return jsonify({"results": []})
|
|
151
|
+
|
|
152
|
+
output = search_doc(target_path, query, max_results=limit)
|
|
153
|
+
return jsonify({"query": query, "output": output})
|
|
154
|
+
|
|
155
|
+
@app.route("/api/paths/add", methods=["POST"])
|
|
156
|
+
def add_custom_path():
|
|
157
|
+
data = request.get_json(silent=True) or {}
|
|
158
|
+
new_path = data.get("path", "").strip()
|
|
159
|
+
if not new_path or not os.path.exists(new_path):
|
|
160
|
+
return jsonify({"success": False, "error": "Invalid or nonexistent directory path"}), 400
|
|
161
|
+
|
|
162
|
+
abs_p = os.path.abspath(new_path)
|
|
163
|
+
cfg = load_config()
|
|
164
|
+
if abs_p not in cfg["custom_roots"]:
|
|
165
|
+
cfg["custom_roots"].append(abs_p)
|
|
166
|
+
if abs_p in cfg["excluded_paths"]:
|
|
167
|
+
cfg["excluded_paths"].remove(abs_p)
|
|
168
|
+
save_config(cfg)
|
|
169
|
+
return jsonify({"success": True, "path": abs_p})
|
|
170
|
+
|
|
171
|
+
@app.route("/api/paths/exclude", methods=["POST"])
|
|
172
|
+
def exclude_custom_path():
|
|
173
|
+
data = request.get_json(silent=True) or {}
|
|
174
|
+
target_path = data.get("path", "").strip()
|
|
175
|
+
if not target_path:
|
|
176
|
+
return jsonify({"success": False, "error": "Missing path parameter"}), 400
|
|
177
|
+
|
|
178
|
+
abs_p = os.path.abspath(target_path)
|
|
179
|
+
cfg = load_config()
|
|
180
|
+
if abs_p in cfg["custom_roots"]:
|
|
181
|
+
cfg["custom_roots"].remove(abs_p)
|
|
182
|
+
if abs_p not in cfg["excluded_paths"]:
|
|
183
|
+
cfg["excluded_paths"].append(abs_p)
|
|
184
|
+
save_config(cfg)
|
|
185
|
+
return jsonify({"success": True, "path": abs_p})
|
|
186
|
+
|
|
187
|
+
return app
|
docgraphical/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# DocGraphical Package Entry
|
|
2
|
+
from .parser import parse_headings, extract_toc, extract_section, search_doc
|
|
3
|
+
|
|
4
|
+
search_file = search_doc
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"parse_headings",
|
|
9
|
+
"extract_toc",
|
|
10
|
+
"extract_section",
|
|
11
|
+
"search_doc",
|
|
12
|
+
"search_file",
|
|
13
|
+
]
|
docgraphical/cli.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""DocGraphical Command Line Interface (CLI)."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from docgraph import __version__
|
|
6
|
+
from docgraphical.parser import extract_toc, extract_section, search_doc
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="docgraph",
|
|
12
|
+
description="DocGraphical: Precision Markdown AST, TOC & Section Slicer for AI Agents & Developers."
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
15
|
+
|
|
16
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
17
|
+
|
|
18
|
+
# Command: toc
|
|
19
|
+
toc_parser = subparsers.add_parser("toc", help="Extract Table of Contents (TOC) with line numbers")
|
|
20
|
+
toc_parser.add_argument("file", help="Path to the Markdown file")
|
|
21
|
+
toc_parser.add_argument("--json", action="store_true", help="Output TOC as JSON format")
|
|
22
|
+
toc_parser.add_argument("--md", action="store_true", help="Output TOC as Markdown list")
|
|
23
|
+
|
|
24
|
+
# Command: section
|
|
25
|
+
sec_parser = subparsers.add_parser("section", help="Extract a specific section by heading")
|
|
26
|
+
sec_parser.add_argument("file", help="Path to the Markdown file")
|
|
27
|
+
sec_parser.add_argument("heading", help="Target heading title (e.g. 'Installation' or '## API')")
|
|
28
|
+
sec_parser.add_argument("--no-sub", action="store_true", help="Exclude subsections under this heading")
|
|
29
|
+
|
|
30
|
+
# Command: search
|
|
31
|
+
search_parser = subparsers.add_parser("search", help="Search keywords across Markdown file or folder")
|
|
32
|
+
search_parser.add_argument("path", help="Path to Markdown file or root directory")
|
|
33
|
+
search_parser.add_argument("query", help="Search keyword or term")
|
|
34
|
+
search_parser.add_argument("--limit", type=int, default=30, help="Maximum number of search results")
|
|
35
|
+
|
|
36
|
+
# Command: mcp
|
|
37
|
+
subparsers.add_parser("mcp", help="Start DocGraphical Model Context Protocol (MCP) server")
|
|
38
|
+
|
|
39
|
+
args = parser.parse_args()
|
|
40
|
+
|
|
41
|
+
if not args.command:
|
|
42
|
+
parser.print_help()
|
|
43
|
+
sys.exit(0)
|
|
44
|
+
|
|
45
|
+
if args.command == "toc":
|
|
46
|
+
fmt = "json" if args.json else ("markdown" if args.md else "text")
|
|
47
|
+
print(extract_toc(args.file, format_type=fmt))
|
|
48
|
+
|
|
49
|
+
elif args.command == "section":
|
|
50
|
+
print(extract_section(args.file, args.heading, include_subsections=not args.no_sub))
|
|
51
|
+
|
|
52
|
+
elif args.command == "search":
|
|
53
|
+
print(search_doc(args.path, args.query, max_results=args.limit))
|
|
54
|
+
|
|
55
|
+
elif args.command == "mcp":
|
|
56
|
+
from docgraphical.mcp_server import run_mcp
|
|
57
|
+
run_mcp()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|