docpybara 0.1.0__tar.gz

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.
Files changed (32) hide show
  1. docpybara-0.1.0/PKG-INFO +26 -0
  2. docpybara-0.1.0/README.md +168 -0
  3. docpybara-0.1.0/pyproject.toml +69 -0
  4. docpybara-0.1.0/setup.cfg +4 -0
  5. docpybara-0.1.0/src/docpybara/__init__.py +3 -0
  6. docpybara-0.1.0/src/docpybara/cli.py +36 -0
  7. docpybara-0.1.0/src/docpybara/routers/__init__.py +0 -0
  8. docpybara-0.1.0/src/docpybara/routers/ai.py +39 -0
  9. docpybara-0.1.0/src/docpybara/routers/files.py +48 -0
  10. docpybara-0.1.0/src/docpybara/routers/search.py +15 -0
  11. docpybara-0.1.0/src/docpybara/server.py +30 -0
  12. docpybara-0.1.0/src/docpybara/services/__init__.py +0 -0
  13. docpybara-0.1.0/src/docpybara/services/ai.py +55 -0
  14. docpybara-0.1.0/src/docpybara/services/filesystem.py +84 -0
  15. docpybara-0.1.0/src/docpybara/services/search.py +61 -0
  16. docpybara-0.1.0/src/docpybara/static/css/style.css +222 -0
  17. docpybara-0.1.0/src/docpybara/static/index.html +48 -0
  18. docpybara-0.1.0/src/docpybara/static/js/ai.js +171 -0
  19. docpybara-0.1.0/src/docpybara/static/js/app.js +247 -0
  20. docpybara-0.1.0/src/docpybara/static/js/cm-bundle.js +29 -0
  21. docpybara-0.1.0/src/docpybara/static/js/editor.js +118 -0
  22. docpybara-0.1.0/src/docpybara/static/js/tree.js +54 -0
  23. docpybara-0.1.0/src/docpybara.egg-info/PKG-INFO +26 -0
  24. docpybara-0.1.0/src/docpybara.egg-info/SOURCES.txt +30 -0
  25. docpybara-0.1.0/src/docpybara.egg-info/dependency_links.txt +1 -0
  26. docpybara-0.1.0/src/docpybara.egg-info/entry_points.txt +2 -0
  27. docpybara-0.1.0/src/docpybara.egg-info/requires.txt +13 -0
  28. docpybara-0.1.0/src/docpybara.egg-info/top_level.txt +1 -0
  29. docpybara-0.1.0/tests/test_ai.py +41 -0
  30. docpybara-0.1.0/tests/test_files_api.py +39 -0
  31. docpybara-0.1.0/tests/test_filesystem.py +71 -0
  32. docpybara-0.1.0/tests/test_search.py +38 -0
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: docpybara
3
+ Version: 0.1.0
4
+ Summary: A lightweight web-based markdown document manager for your repository
5
+ Author: mjk
6
+ License-Expression: MIT
7
+ Keywords: markdown,docs,editor,web
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Framework :: FastAPI
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: fastapi>=0.111.0
17
+ Requires-Dist: uvicorn>=0.23.0
18
+ Requires-Dist: click>=8.0.0
19
+ Requires-Dist: python-dotenv>=1.0.0
20
+ Provides-Extra: ai
21
+ Requires-Dist: openai>=1.0.0; extra == "ai"
22
+ Requires-Dist: anthropic>=0.20.0; extra == "ai"
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
25
+ Requires-Dist: httpx>=0.24.0; extra == "dev"
26
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
@@ -0,0 +1,168 @@
1
+ # docpybara
2
+
3
+ A lightweight, pip-installable web-based markdown document manager. Point it at any directory and manage your `.md` files through a clean dark-themed web UI with AI-powered text refinement.
4
+
5
+ Built as a final project for Stanford CS146S: The Modern Software Developer.
6
+
7
+ ## Features
8
+
9
+ - **File tree** — Browse `.md` files in a collapsible sidebar tree view
10
+ - **CodeMirror 6 editor** — Syntax-highlighted markdown editing with formatting toolbar (bold, italic, heading, link, list)
11
+ - **Full CRUD** — Create, read, update, and delete markdown files from the browser
12
+ - **Server-side search** — Search by filename and content with context snippets
13
+ - **AI text refinement** — Select text and refine it with OpenAI gpt-5-mini (concise, fix grammar, translate, change tone, or custom instructions)
14
+ - **Save feedback** — Visual toast notifications and status indicators on save
15
+ - **Keyboard shortcuts** — Ctrl/Cmd+S to save
16
+ - **Zero config** — `pip install` and run, no build step or database needed
17
+
18
+ ## Quick Start
19
+
20
+ ### Prerequisites
21
+ - Python 3.10+
22
+ - (Optional) OpenAI API key for AI features
23
+
24
+ ### Installation & Run
25
+
26
+ ```bash
27
+ # Install
28
+ pip install -e ".[ai]"
29
+
30
+ # Create a .env file with your OpenAI key (optional, for AI features)
31
+ cp .env.example .env
32
+ # Edit .env and add your OPENAI_API_KEY
33
+
34
+ # Serve a directory
35
+ docpybara --dir ./docs
36
+
37
+ # Open http://localhost:8000 in your browser
38
+ ```
39
+
40
+ ### CLI Options
41
+
42
+ ```
43
+ docpybara [OPTIONS]
44
+
45
+ Options:
46
+ --dir TEXT Root directory to serve (default: current directory)
47
+ --port INTEGER Port number (default: 8000)
48
+ --host TEXT Host to bind to (default: 0.0.0.0)
49
+ --reload Enable auto-reload for development
50
+ --help Show help message
51
+ ```
52
+
53
+ ### Development Commands (Makefile)
54
+
55
+ ```bash
56
+ make install # pip install -e ".[dev,ai]"
57
+ make run # Start server (port 8000)
58
+ make dev # Start with auto-reload
59
+ make test # Run pytest
60
+ make lint # Run ruff check
61
+ make format # Run ruff --fix
62
+ make build # Build package (dist/)
63
+ make clean # Remove build artifacts
64
+ ```
65
+
66
+ ## AI Features
67
+
68
+ AI text refinement uses OpenAI gpt-5-mini. Set your API key in `.env`:
69
+
70
+ ```bash
71
+ OPENAI_API_KEY=sk-...
72
+ ```
73
+
74
+ ### Usage
75
+ 1. Open a file in the editor
76
+ 2. Select text you want to refine
77
+ 3. Click the **AI** button in the toolbar
78
+ 4. Choose a preset or type a custom instruction
79
+ 5. Preview the result and click **Apply**
80
+
81
+ ### Presets
82
+
83
+ | Preset | Description |
84
+ |--------|-------------|
85
+ | Concise | Make text more concise |
86
+ | Fix grammar | Fix spelling and punctuation |
87
+ | English | Translate to English |
88
+ | Korean | Translate to Korean |
89
+ | Formal | Professional tone |
90
+ | Casual | Friendly tone |
91
+
92
+ ## Architecture
93
+
94
+ ```
95
+ docpybara/
96
+ ├── pyproject.toml # Package metadata + CLI entry point
97
+ ├── Makefile # Dev commands
98
+ ├── src/docpybara/
99
+ │ ├── __init__.py # Version
100
+ │ ├── cli.py # Click CLI + .env loading
101
+ │ ├── server.py # FastAPI app factory
102
+ │ ├── routers/
103
+ │ │ ├── files.py # File CRUD (GET/POST/PUT/DELETE)
104
+ │ │ ├── search.py # Search endpoint
105
+ │ │ └── ai.py # AI refinement endpoint
106
+ │ ├── services/
107
+ │ │ ├── filesystem.py # File I/O + path traversal protection
108
+ │ │ ├── search.py # Filename + content search
109
+ │ │ └── ai.py # OpenAI gpt-5-mini integration
110
+ │ └── static/
111
+ │ ├── index.html # SPA entry point
112
+ │ ├── css/style.css # Dark theme (Catppuccin-inspired)
113
+ │ └── js/
114
+ │ ├── app.js # Main app logic + toast notifications
115
+ │ ├── tree.js # File tree component
116
+ │ ├── editor.js # CodeMirror 6 wrapper
117
+ │ ├── ai.js # AI refinement dialog UI
118
+ │ └── cm-bundle.js # Pre-built CodeMirror bundle
119
+ └── tests/ # 25 tests (pytest)
120
+ ├── conftest.py
121
+ ├── test_filesystem.py
122
+ ├── test_files_api.py
123
+ ├── test_search.py
124
+ └── test_ai.py
125
+ ```
126
+
127
+ ### Tech Stack
128
+
129
+ | Layer | Technology |
130
+ |-------|-----------|
131
+ | Backend | FastAPI + uvicorn |
132
+ | Frontend | Vanilla JS + CodeMirror 6 (pre-built bundle) |
133
+ | CLI | Click + python-dotenv |
134
+ | AI | OpenAI SDK (gpt-5-mini) |
135
+ | Testing | pytest + httpx |
136
+ | Linting | ruff (E, F, I, UP, B, SIM, RUF) |
137
+
138
+ ### API Endpoints
139
+
140
+ | Method | Path | Description |
141
+ |--------|------|-------------|
142
+ | GET | `/api/tree` | File tree |
143
+ | GET | `/api/files/{path}` | Read file |
144
+ | PUT | `/api/files/{path}` | Update file |
145
+ | POST | `/api/files/{path}` | Create file |
146
+ | DELETE | `/api/files/{path}` | Delete file |
147
+ | GET | `/api/search?q=` | Search files |
148
+ | POST | `/api/ai/refine` | AI text refinement |
149
+ | GET | `/api/ai/presets` | List AI presets |
150
+
151
+ ## Design Decisions
152
+
153
+ - **No build step for frontend** — CodeMirror is pre-bundled via esbuild and shipped as a static asset. No npm/webpack needed at runtime.
154
+ - **File-system backed** — No database. Files are read/written directly to disk. What you see in the UI is what's on disk.
155
+ - **Path traversal protection** — All file paths are resolved and validated against the root directory before any I/O operation.
156
+ - **AI as optional** — Core editing works without any API key. AI features show a clear error message when no key is configured.
157
+ - **pip-installable** — Single `pip install` gives you a CLI tool. No Docker, no config files required.
158
+ - **Dark theme** — Catppuccin-inspired color scheme for comfortable long-session editing.
159
+
160
+ ## Security
161
+
162
+ - Path traversal attacks are blocked by resolving all paths and verifying they stay within the configured root directory
163
+ - API keys are loaded from environment variables or `.env` files, never exposed to the frontend
164
+ - The AI refinement endpoint proxies requests through the server so client never sees the API key
165
+
166
+ ## License
167
+
168
+ MIT
@@ -0,0 +1,69 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "docpybara"
7
+ version = "0.1.0"
8
+ description = "A lightweight web-based markdown document manager for your repository"
9
+ license = "MIT"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "mjk" }]
12
+ keywords = ["markdown", "docs", "editor", "web"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Framework :: FastAPI",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ ]
22
+ dependencies = [
23
+ "fastapi>=0.111.0",
24
+ "uvicorn>=0.23.0",
25
+ "click>=8.0.0",
26
+ "python-dotenv>=1.0.0",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ ai = [
31
+ "openai>=1.0.0",
32
+ "anthropic>=0.20.0",
33
+ ]
34
+ dev = [
35
+ "pytest>=7.0.0",
36
+ "httpx>=0.24.0",
37
+ "ruff>=0.4.0",
38
+ ]
39
+
40
+ [project.scripts]
41
+ docpybara = "docpybara.cli:main"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ [tool.setuptools.package-data]
47
+ docpybara = ["static/**/*"]
48
+
49
+ [tool.ruff]
50
+ line-length = 100
51
+ target-version = "py310"
52
+
53
+ [tool.ruff.lint]
54
+ select = [
55
+ "E", # pycodestyle errors
56
+ "F", # pyflakes
57
+ "I", # isort
58
+ "UP", # pyupgrade
59
+ "B", # flake8-bugbear
60
+ "SIM", # flake8-simplify
61
+ "RUF", # ruff-specific rules
62
+ ]
63
+ ignore = ["E501"]
64
+
65
+ [tool.ruff.lint.isort]
66
+ known-first-party = ["docpybara"]
67
+
68
+ [tool.pytest.ini_options]
69
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """docpybara - A lightweight web-based markdown document manager."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,36 @@
1
+ """CLI entry point for docpybara."""
2
+
3
+ import click
4
+ import uvicorn
5
+
6
+
7
+ @click.command()
8
+ @click.option("--dir", "root_dir", default=".", help="Root directory to serve markdown files from")
9
+ @click.option("--port", default=8000, help="Port to run the server on")
10
+ @click.option("--host", default="0.0.0.0", help="Host to bind the server to")
11
+ @click.option("--reload", is_flag=True, help="Enable auto-reload for development")
12
+ def main(root_dir: str, port: int, host: str, reload: bool) -> None:
13
+ """docpybara - A lightweight web-based markdown document manager."""
14
+ import os
15
+ from pathlib import Path
16
+
17
+ # Load .env from the root_dir or current directory
18
+ for env_path in [Path(root_dir) / ".env", Path(".env")]:
19
+ if env_path.is_file():
20
+ from dotenv import load_dotenv
21
+
22
+ load_dotenv(env_path)
23
+ break
24
+
25
+ os.environ["DOCPYBARA_ROOT_DIR"] = os.path.abspath(root_dir)
26
+
27
+ click.echo(f"docpybara v0.1.0 serving '{os.path.abspath(root_dir)}'")
28
+ click.echo(f"Open http://{host}:{port} in your browser")
29
+
30
+ uvicorn.run(
31
+ "docpybara.server:create_app",
32
+ factory=True,
33
+ host=host,
34
+ port=port,
35
+ reload=reload,
36
+ )
File without changes
@@ -0,0 +1,39 @@
1
+ """AI text refinement API endpoint."""
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from pydantic import BaseModel
5
+
6
+ from docpybara.services.ai import PRESETS, refine_text
7
+
8
+ router = APIRouter(tags=["ai"])
9
+
10
+
11
+ class RefineRequest(BaseModel):
12
+ text: str
13
+ instruction: str = ""
14
+ preset: str | None = None
15
+
16
+
17
+ class RefineResponse(BaseModel):
18
+ refined: str
19
+ model: str
20
+
21
+
22
+ @router.post("/ai/refine", response_model=RefineResponse)
23
+ async def api_refine(body: RefineRequest) -> RefineResponse:
24
+ """Refine selected text using AI."""
25
+ if not body.text.strip():
26
+ raise HTTPException(status_code=400, detail="Text cannot be empty")
27
+ try:
28
+ result = await refine_text(body.text, body.instruction, body.preset)
29
+ return RefineResponse(**result)
30
+ except ValueError as e:
31
+ raise HTTPException(status_code=422, detail=str(e)) from None
32
+ except Exception as e:
33
+ raise HTTPException(status_code=502, detail=f"AI service error: {e}") from None
34
+
35
+
36
+ @router.get("/ai/presets")
37
+ async def api_presets() -> dict[str, str]:
38
+ """Get available refinement presets."""
39
+ return PRESETS
@@ -0,0 +1,48 @@
1
+ """File management API endpoints."""
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, Request
6
+ from pydantic import BaseModel
7
+
8
+ from docpybara.services.filesystem import create_file, delete_file, get_tree, read_file, write_file
9
+
10
+ router = APIRouter(tags=["files"])
11
+
12
+
13
+ class FileContent(BaseModel):
14
+ content: str = ""
15
+
16
+
17
+ @router.get("/tree")
18
+ async def api_get_tree(request: Request) -> list[dict[str, Any]]:
19
+ """Get the markdown file tree."""
20
+ return get_tree(request.app.state.root_dir)
21
+
22
+
23
+ @router.get("/files/{path:path}")
24
+ async def api_read_file(path: str, request: Request) -> dict[str, str]:
25
+ """Read a markdown file."""
26
+ content = read_file(request.app.state.root_dir, path)
27
+ return {"path": path, "content": content}
28
+
29
+
30
+ @router.put("/files/{path:path}")
31
+ async def api_write_file(path: str, body: FileContent, request: Request) -> dict[str, str]:
32
+ """Update a markdown file."""
33
+ write_file(request.app.state.root_dir, path, body.content)
34
+ return {"path": path, "status": "saved"}
35
+
36
+
37
+ @router.post("/files/{path:path}", status_code=201)
38
+ async def api_create_file(path: str, body: FileContent, request: Request) -> dict[str, str]:
39
+ """Create a new markdown file."""
40
+ create_file(request.app.state.root_dir, path, body.content)
41
+ return {"path": path, "status": "created"}
42
+
43
+
44
+ @router.delete("/files/{path:path}")
45
+ async def api_delete_file(path: str, request: Request) -> dict[str, str]:
46
+ """Delete a markdown file."""
47
+ delete_file(request.app.state.root_dir, path)
48
+ return {"path": path, "status": "deleted"}
@@ -0,0 +1,15 @@
1
+ """Search API endpoint."""
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, Request
6
+
7
+ from docpybara.services.search import search_files
8
+
9
+ router = APIRouter(tags=["search"])
10
+
11
+
12
+ @router.get("/search")
13
+ async def api_search(q: str, request: Request) -> list[dict[str, Any]]:
14
+ """Search markdown files by filename and content."""
15
+ return search_files(request.app.state.root_dir, q)
@@ -0,0 +1,30 @@
1
+ """FastAPI application factory."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.staticfiles import StaticFiles
8
+
9
+
10
+ def create_app() -> FastAPI:
11
+ """Create and configure the FastAPI application."""
12
+ app = FastAPI(title="docpybara", version="0.1.0")
13
+
14
+ root_dir = os.environ.get("DOCPYBARA_ROOT_DIR", ".")
15
+ app.state.root_dir = Path(root_dir).resolve()
16
+
17
+ # Register routers
18
+ from docpybara.routers.ai import router as ai_router
19
+ from docpybara.routers.files import router as files_router
20
+ from docpybara.routers.search import router as search_router
21
+
22
+ app.include_router(files_router, prefix="/api")
23
+ app.include_router(search_router, prefix="/api")
24
+ app.include_router(ai_router, prefix="/api")
25
+
26
+ # Serve static frontend files
27
+ static_dir = Path(__file__).parent / "static"
28
+ app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
29
+
30
+ return app
File without changes
@@ -0,0 +1,55 @@
1
+ """AI text refinement service using OpenAI."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ PRESETS: dict[str, str] = {
7
+ "concise": "Make this text more concise while preserving the meaning.",
8
+ "fix": "Fix grammar, spelling, and punctuation errors. Keep the original tone.",
9
+ "translate_en": "Translate this text to English. Preserve formatting.",
10
+ "translate_ko": "Translate this text to Korean. Preserve formatting.",
11
+ "formal": "Rewrite this text in a more formal, professional tone.",
12
+ "casual": "Rewrite this text in a more casual, friendly tone.",
13
+ }
14
+
15
+ MODEL = "gpt-5-mini"
16
+
17
+
18
+ async def refine_text(
19
+ text: str,
20
+ instruction: str,
21
+ preset: str | None = None,
22
+ ) -> dict[str, Any]:
23
+ """Refine text using OpenAI gpt-5-mini."""
24
+ from openai import AsyncOpenAI
25
+
26
+ api_key = os.environ.get("OPENAI_API_KEY")
27
+ if not api_key:
28
+ raise ValueError("No API key configured. Set OPENAI_API_KEY environment variable.")
29
+
30
+ if preset and preset in PRESETS:
31
+ instruction = PRESETS[preset]
32
+
33
+ if not instruction:
34
+ instruction = "Improve this text."
35
+
36
+ client = AsyncOpenAI(api_key=api_key)
37
+ response = await client.chat.completions.create(
38
+ model=MODEL,
39
+ messages=[
40
+ {
41
+ "role": "system",
42
+ "content": (
43
+ "You are a writing assistant. "
44
+ "The user will give you text and an instruction. "
45
+ "Return ONLY the refined text, nothing else. "
46
+ "Do not add explanations, preamble, or formatting wrappers."
47
+ ),
48
+ },
49
+ {"role": "user", "content": f"Instruction: {instruction}\n\nText:\n{text}"},
50
+ ],
51
+ )
52
+ return {
53
+ "refined": response.choices[0].message.content,
54
+ "model": MODEL,
55
+ }
@@ -0,0 +1,84 @@
1
+ """Filesystem service for markdown file operations."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from fastapi import HTTPException
7
+
8
+
9
+ def _validate_path(root_dir: Path, relative_path: str) -> Path:
10
+ """Validate and resolve a path, preventing path traversal attacks."""
11
+ resolved = (root_dir / relative_path).resolve()
12
+ if not str(resolved).startswith(str(root_dir)):
13
+ raise HTTPException(status_code=403, detail="Access denied: path traversal detected")
14
+ return resolved
15
+
16
+
17
+ def get_tree(root_dir: Path) -> list[dict[str, Any]]:
18
+ """Get a recursive tree of markdown files under root_dir."""
19
+ if not root_dir.is_dir():
20
+ return []
21
+
22
+ def _build_tree(directory: Path) -> list[dict[str, Any]]:
23
+ entries: list[dict[str, Any]] = []
24
+ try:
25
+ items = sorted(directory.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
26
+ except PermissionError:
27
+ return entries
28
+
29
+ for item in items:
30
+ if item.name.startswith("."):
31
+ continue
32
+ if item.is_dir():
33
+ children = _build_tree(item)
34
+ if children:
35
+ entries.append({
36
+ "name": item.name,
37
+ "path": str(item.relative_to(root_dir)),
38
+ "type": "directory",
39
+ "children": children,
40
+ })
41
+ elif item.suffix.lower() == ".md":
42
+ entries.append({
43
+ "name": item.name,
44
+ "path": str(item.relative_to(root_dir)),
45
+ "type": "file",
46
+ })
47
+ return entries
48
+
49
+ return _build_tree(root_dir)
50
+
51
+
52
+ def read_file(root_dir: Path, relative_path: str) -> str:
53
+ """Read a markdown file and return its content."""
54
+ file_path = _validate_path(root_dir, relative_path)
55
+ if not file_path.is_file():
56
+ raise HTTPException(status_code=404, detail=f"File not found: {relative_path}")
57
+ return file_path.read_text(encoding="utf-8")
58
+
59
+
60
+ def write_file(root_dir: Path, relative_path: str, content: str) -> None:
61
+ """Write content to a markdown file."""
62
+ file_path = _validate_path(root_dir, relative_path)
63
+ if not file_path.is_file():
64
+ raise HTTPException(status_code=404, detail=f"File not found: {relative_path}")
65
+ file_path.write_text(content, encoding="utf-8")
66
+
67
+
68
+ def create_file(root_dir: Path, relative_path: str, content: str = "") -> None:
69
+ """Create a new markdown file."""
70
+ if not relative_path.endswith(".md"):
71
+ relative_path += ".md"
72
+ file_path = _validate_path(root_dir, relative_path)
73
+ if file_path.exists():
74
+ raise HTTPException(status_code=409, detail=f"File already exists: {relative_path}")
75
+ file_path.parent.mkdir(parents=True, exist_ok=True)
76
+ file_path.write_text(content, encoding="utf-8")
77
+
78
+
79
+ def delete_file(root_dir: Path, relative_path: str) -> None:
80
+ """Delete a markdown file."""
81
+ file_path = _validate_path(root_dir, relative_path)
82
+ if not file_path.is_file():
83
+ raise HTTPException(status_code=404, detail=f"File not found: {relative_path}")
84
+ file_path.unlink()
@@ -0,0 +1,61 @@
1
+ """Search service for markdown files."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+
7
+ def search_files(root_dir: Path, query: str, max_results: int = 50) -> list[dict[str, Any]]:
8
+ """Search markdown files by filename and content."""
9
+ if not query.strip():
10
+ return []
11
+
12
+ query_lower = query.lower()
13
+ results: list[dict[str, Any]] = []
14
+
15
+ if not root_dir.is_dir():
16
+ return results
17
+
18
+ for md_file in sorted(root_dir.rglob("*.md")):
19
+ if md_file.name.startswith("."):
20
+ continue
21
+ # Skip hidden directories
22
+ if any(part.startswith(".") for part in md_file.relative_to(root_dir).parts):
23
+ continue
24
+
25
+ rel_path = str(md_file.relative_to(root_dir))
26
+ name_match = query_lower in md_file.name.lower()
27
+
28
+ try:
29
+ content = md_file.read_text(encoding="utf-8")
30
+ except (PermissionError, UnicodeDecodeError):
31
+ continue
32
+
33
+ content_lower = content.lower()
34
+ content_match = query_lower in content_lower
35
+
36
+ if name_match or content_match:
37
+ context = ""
38
+ if content_match:
39
+ idx = content_lower.index(query_lower)
40
+ start = max(0, idx - 40)
41
+ end = min(len(content), idx + len(query) + 40)
42
+ context = content[start:end].replace("\n", " ").strip()
43
+ if start > 0:
44
+ context = "..." + context
45
+ if end < len(content):
46
+ context = context + "..."
47
+
48
+ results.append({
49
+ "path": rel_path,
50
+ "name": md_file.name,
51
+ "name_match": name_match,
52
+ "content_match": content_match,
53
+ "context": context,
54
+ })
55
+
56
+ if len(results) >= max_results:
57
+ break
58
+
59
+ # Sort: name matches first, then content matches
60
+ results.sort(key=lambda r: (not r["name_match"], r["path"]))
61
+ return results