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/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
@@ -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()