doorstop-vscode-server 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 (34) hide show
  1. doorstop_vscode_server-0.1.0/LICENSE +21 -0
  2. doorstop_vscode_server-0.1.0/PKG-INFO +63 -0
  3. doorstop_vscode_server-0.1.0/README.md +38 -0
  4. doorstop_vscode_server-0.1.0/pyproject.toml +49 -0
  5. doorstop_vscode_server-0.1.0/setup.cfg +4 -0
  6. doorstop_vscode_server-0.1.0/src/doorstop_server/__init__.py +1 -0
  7. doorstop_vscode_server-0.1.0/src/doorstop_server/__main__.py +4 -0
  8. doorstop_vscode_server-0.1.0/src/doorstop_server/app.py +22 -0
  9. doorstop_vscode_server-0.1.0/src/doorstop_server/cli.py +27 -0
  10. doorstop_vscode_server-0.1.0/src/doorstop_server/config.py +8 -0
  11. doorstop_vscode_server-0.1.0/src/doorstop_server/deps.py +13 -0
  12. doorstop_vscode_server-0.1.0/src/doorstop_server/doorstop_tree.py +20 -0
  13. doorstop_vscode_server-0.1.0/src/doorstop_server/errors.py +34 -0
  14. doorstop_vscode_server-0.1.0/src/doorstop_server/lock.py +34 -0
  15. doorstop_vscode_server-0.1.0/src/doorstop_server/routers/__init__.py +0 -0
  16. doorstop_vscode_server-0.1.0/src/doorstop_server/routers/documents.py +100 -0
  17. doorstop_vscode_server-0.1.0/src/doorstop_server/routers/health.py +11 -0
  18. doorstop_vscode_server-0.1.0/src/doorstop_server/routers/items.py +18 -0
  19. doorstop_vscode_server-0.1.0/src/doorstop_server/routers/review.py +41 -0
  20. doorstop_vscode_server-0.1.0/src/doorstop_server/routers/tree.py +53 -0
  21. doorstop_vscode_server-0.1.0/src/doorstop_server/schemas.py +116 -0
  22. doorstop_vscode_server-0.1.0/src/doorstop_vscode_server.egg-info/PKG-INFO +63 -0
  23. doorstop_vscode_server-0.1.0/src/doorstop_vscode_server.egg-info/SOURCES.txt +32 -0
  24. doorstop_vscode_server-0.1.0/src/doorstop_vscode_server.egg-info/dependency_links.txt +1 -0
  25. doorstop_vscode_server-0.1.0/src/doorstop_vscode_server.egg-info/entry_points.txt +2 -0
  26. doorstop_vscode_server-0.1.0/src/doorstop_vscode_server.egg-info/requires.txt +12 -0
  27. doorstop_vscode_server-0.1.0/src/doorstop_vscode_server.egg-info/top_level.txt +1 -0
  28. doorstop_vscode_server-0.1.0/tests/test_documents.py +171 -0
  29. doorstop_vscode_server-0.1.0/tests/test_errors.py +51 -0
  30. doorstop_vscode_server-0.1.0/tests/test_health.py +7 -0
  31. doorstop_vscode_server-0.1.0/tests/test_items.py +39 -0
  32. doorstop_vscode_server-0.1.0/tests/test_review.py +105 -0
  33. doorstop_vscode_server-0.1.0/tests/test_serialization.py +69 -0
  34. doorstop_vscode_server-0.1.0/tests/test_tree.py +75 -0
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2026 Simon Walbrun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: doorstop-vscode-server
3
+ Version: 0.1.0
4
+ Summary: FastAPI server wrapping the Doorstop Python API for the Doorstop VS Code extension
5
+ Author: Simon Walbrun
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/simonwalbrun-lab/doorstop-vscode-server
8
+ Project-URL: Issues, https://github.com/simonwalbrun-lab/doorstop-vscode-server/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: doorstop>=3.0
15
+ Requires-Dist: fastapi>=0.115
16
+ Requires-Dist: uvicorn[standard]>=0.30
17
+ Requires-Dist: pydantic>=2
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8; extra == "dev"
20
+ Requires-Dist: httpx>=0.27; extra == "dev"
21
+ Provides-Extra: build
22
+ Requires-Dist: build; extra == "build"
23
+ Requires-Dist: twine; extra == "build"
24
+ Dynamic: license-file
25
+
26
+ # doorstop-vscode-server
27
+
28
+ A small FastAPI server that wraps the [Doorstop](https://pypi.org/project/doorstop/) Python API directly (instead of shelling out to the `doorstop` CLI). It is designed to serve exactly one client — the Doorstop VS Code extension — and answers requests strictly one at a time; there is no concurrent request handling.
29
+
30
+ ## Install (editable, for development)
31
+
32
+ ```bash
33
+ pip install -e .[dev]
34
+ ```
35
+
36
+ ## Run
37
+
38
+ ```bash
39
+ python -m doorstop_server --project <path-to-doorstop-project-root> --host 127.0.0.1 --port 7867
40
+ ```
41
+
42
+ or, after installing:
43
+
44
+ ```bash
45
+ doorstop-vscode-server --project <path-to-doorstop-project-root> --host 127.0.0.1 --port 7867
46
+ ```
47
+
48
+ ## API
49
+
50
+ See `src/doorstop_server/routers/` for the endpoint implementations. `GET /health` is the only route that is not serialized behind the request lock.
51
+
52
+ ## Tests
53
+
54
+ The test suite exercises the real FastAPI app against a temporary Doorstop project (no mocking of Doorstop itself), so it doubles as a pinned-down contract for the HTTP interface — every request/response shape, status code, and error format a test asserts on is something the extension can rely on.
55
+
56
+ ```bash
57
+ pip install -e .[dev]
58
+ pytest
59
+ ```
60
+
61
+ - `tests/conftest.py` — shared fixtures (`client`, `project_root`, `document`) and a `set_item_text` helper for mutating an item directly via the Doorstop API (bypassing the server) to simulate out-of-band edits.
62
+ - `tests/test_serialization.py` — proves the one-request-at-a-time guarantee black-box: fires concurrent `/items` calls and checks for zero UID collisions/gaps, rather than inspecting the lock directly.
63
+ - `tests/test_errors.py` — pins down the structured `{"error": {"code", "message"}}` shape for both expected (`DoorstopError`) and unexpected exceptions.
@@ -0,0 +1,38 @@
1
+ # doorstop-vscode-server
2
+
3
+ A small FastAPI server that wraps the [Doorstop](https://pypi.org/project/doorstop/) Python API directly (instead of shelling out to the `doorstop` CLI). It is designed to serve exactly one client — the Doorstop VS Code extension — and answers requests strictly one at a time; there is no concurrent request handling.
4
+
5
+ ## Install (editable, for development)
6
+
7
+ ```bash
8
+ pip install -e .[dev]
9
+ ```
10
+
11
+ ## Run
12
+
13
+ ```bash
14
+ python -m doorstop_server --project <path-to-doorstop-project-root> --host 127.0.0.1 --port 7867
15
+ ```
16
+
17
+ or, after installing:
18
+
19
+ ```bash
20
+ doorstop-vscode-server --project <path-to-doorstop-project-root> --host 127.0.0.1 --port 7867
21
+ ```
22
+
23
+ ## API
24
+
25
+ See `src/doorstop_server/routers/` for the endpoint implementations. `GET /health` is the only route that is not serialized behind the request lock.
26
+
27
+ ## Tests
28
+
29
+ The test suite exercises the real FastAPI app against a temporary Doorstop project (no mocking of Doorstop itself), so it doubles as a pinned-down contract for the HTTP interface — every request/response shape, status code, and error format a test asserts on is something the extension can rely on.
30
+
31
+ ```bash
32
+ pip install -e .[dev]
33
+ pytest
34
+ ```
35
+
36
+ - `tests/conftest.py` — shared fixtures (`client`, `project_root`, `document`) and a `set_item_text` helper for mutating an item directly via the Doorstop API (bypassing the server) to simulate out-of-band edits.
37
+ - `tests/test_serialization.py` — proves the one-request-at-a-time guarantee black-box: fires concurrent `/items` calls and checks for zero UID collisions/gaps, rather than inspecting the lock directly.
38
+ - `tests/test_errors.py` — pins down the structured `{"error": {"code", "message"}}` shape for both expected (`DoorstopError`) and unexpected exceptions.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "doorstop-vscode-server"
3
+ version = "0.1.0"
4
+ authors = [
5
+ { name="Simon Walbrun" },
6
+ ]
7
+ description = "FastAPI server wrapping the Doorstop Python API for the Doorstop VS Code extension"
8
+ readme = "README.md"
9
+ requires-python = ">=3.9"
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Operating System :: OS Independent",
13
+ ]
14
+ license = "MIT"
15
+ license-files = ["LICEN[CS]E*"]
16
+
17
+
18
+ dependencies = [
19
+ "doorstop>=3.0",
20
+ "fastapi>=0.115",
21
+ "uvicorn[standard]>=0.30",
22
+ "pydantic>=2",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=8", "httpx>=0.27"]
27
+ build = ["build", "twine"]
28
+
29
+ [project.scripts]
30
+ doorstop-vscode-server = "doorstop_server.cli:main"
31
+
32
+ [build-system]
33
+ requires = ["setuptools>=68", "wheel"]
34
+ build-backend = "setuptools.build_meta"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+
42
+ license = {text = "MIT"}
43
+ classifiers = [
44
+ "License :: OSI Approved :: MIT License",
45
+ ]
46
+
47
+ [project.urls]
48
+ Homepage = "https://github.com/simonwalbrun-lab/doorstop-vscode-server"
49
+ Issues = "https://github.com/simonwalbrun-lab/doorstop-vscode-server/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """FastAPI server wrapping the Doorstop Python API."""
@@ -0,0 +1,4 @@
1
+ from doorstop_server.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,22 @@
1
+ from fastapi import FastAPI
2
+
3
+ from doorstop_server.config import Settings
4
+ from doorstop_server.errors import register_exception_handlers
5
+ from doorstop_server.lock import SerializeRequestsMiddleware
6
+ from doorstop_server.routers import documents, health, items, review, tree
7
+
8
+
9
+ def create_app(settings: Settings) -> FastAPI:
10
+ app = FastAPI(title="doorstop-vscode-server")
11
+ app.state.settings = settings
12
+
13
+ app.add_middleware(SerializeRequestsMiddleware)
14
+ register_exception_handlers(app)
15
+
16
+ app.include_router(health.router)
17
+ app.include_router(documents.router)
18
+ app.include_router(items.router)
19
+ app.include_router(review.router)
20
+ app.include_router(tree.router)
21
+
22
+ return app
@@ -0,0 +1,27 @@
1
+ import argparse
2
+
3
+ import uvicorn
4
+
5
+ from doorstop_server.app import create_app
6
+ from doorstop_server.config import Settings
7
+
8
+
9
+ def parse_args(argv=None) -> Settings:
10
+ parser = argparse.ArgumentParser(prog="doorstop-vscode-server")
11
+ parser.add_argument("--project", required=True, help="path to the root of the Doorstop project")
12
+ parser.add_argument("--host", default="127.0.0.1")
13
+ parser.add_argument("--port", type=int, default=7867)
14
+ args = parser.parse_args(argv)
15
+ return Settings(project_root=args.project, host=args.host, port=args.port)
16
+
17
+
18
+ def main(argv=None) -> None:
19
+ settings = parse_args(argv)
20
+ app = create_app(settings)
21
+ # workers=1, no reload: exactly one process, one event loop -- required for
22
+ # the request-serialization guarantee in lock.py to hold.
23
+ uvicorn.run(app, host=settings.host, port=settings.port, workers=1, reload=False)
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()
@@ -0,0 +1,8 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class Settings:
6
+ project_root: str
7
+ host: str
8
+ port: int
@@ -0,0 +1,13 @@
1
+ import doorstop
2
+ from fastapi import Request
3
+
4
+ from doorstop_server.config import Settings
5
+ from doorstop_server.doorstop_tree import load_tree
6
+
7
+
8
+ def get_settings(request: Request) -> Settings:
9
+ return request.app.state.settings
10
+
11
+
12
+ def get_tree(request: Request) -> doorstop.Tree:
13
+ return load_tree(get_settings(request))
@@ -0,0 +1,20 @@
1
+ import doorstop
2
+
3
+ from doorstop_server.config import Settings
4
+
5
+
6
+ def load_tree(settings: Settings) -> doorstop.Tree:
7
+ """Build a fresh Tree from disk.
8
+
9
+ Rebuilt on every request rather than cached: the user can hand-edit item
10
+ files in VS Code between two server calls, and a cached tree would
11
+ silently miss or clobber those edits. ``request_next_number=None`` is the
12
+ ``--force`` equivalent the CLI uses to skip Doorstop's own numbering
13
+ server -- safe here because this server is the only process ever
14
+ mutating the project and every request is fully serialized.
15
+ """
16
+ return doorstop.build(
17
+ cwd=settings.project_root,
18
+ root=settings.project_root,
19
+ request_next_number=None,
20
+ )
@@ -0,0 +1,34 @@
1
+ from doorstop.common import DoorstopError
2
+ from fastapi import FastAPI, Request
3
+ from fastapi.responses import JSONResponse
4
+
5
+
6
+ class DoorstopApiError(Exception):
7
+ """A request-level error raised by a route handler (bad input, not found, ...)."""
8
+
9
+ def __init__(self, status_code: int, code: str, message: str):
10
+ super().__init__(message)
11
+ self.status_code = status_code
12
+ self.code = code
13
+ self.message = message
14
+
15
+
16
+ def _error_response(status_code: int, code: str, message: str) -> JSONResponse:
17
+ return JSONResponse(
18
+ status_code=status_code,
19
+ content={"error": {"code": code, "message": message}},
20
+ )
21
+
22
+
23
+ def register_exception_handlers(app: FastAPI) -> None:
24
+ @app.exception_handler(DoorstopApiError)
25
+ async def handle_api_error(_request: Request, exc: DoorstopApiError) -> JSONResponse:
26
+ return _error_response(exc.status_code, exc.code, exc.message)
27
+
28
+ @app.exception_handler(DoorstopError)
29
+ async def handle_doorstop_error(_request: Request, exc: DoorstopError) -> JSONResponse:
30
+ return _error_response(400, "DOORSTOP_ERROR", str(exc))
31
+
32
+ @app.exception_handler(Exception)
33
+ async def handle_unexpected_error(_request: Request, exc: Exception) -> JSONResponse:
34
+ return _error_response(500, "INTERNAL_ERROR", str(exc))
@@ -0,0 +1,34 @@
1
+ import asyncio
2
+
3
+ from starlette.types import ASGIApp, Receive, Scope, Send
4
+
5
+ _UNLOCKED_PATHS = {"/health"}
6
+
7
+
8
+ class SerializeRequestsMiddleware:
9
+ """Ensures the app answers exactly one request at a time (except /health).
10
+
11
+ Implemented as a bare ASGI middleware -- not `@app.middleware("http")`
12
+ (Starlette's BaseHTTPMiddleware) -- because BaseHTTPMiddleware runs the
13
+ downstream app inside a separate anyio task/stream that our
14
+ @app.exception_handler() registrations cannot see through: an unexpected
15
+ exception raised in a route would bypass the structured {"error": ...}
16
+ response entirely and blow past this middleware as a raw exception. A plain
17
+ ASGI middleware just awaits the inner app directly, so by the time control
18
+ returns here any exception has already been turned into a normal response.
19
+
20
+ The lock is created per middleware instance (i.e. per app / per
21
+ create_app() call), not as a module-level global, so it's always bound to
22
+ whatever event loop first serves that app.
23
+ """
24
+
25
+ def __init__(self, app: ASGIApp) -> None:
26
+ self.app = app
27
+ self.lock = asyncio.Lock()
28
+
29
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
30
+ if scope["type"] != "http" or scope["path"] in _UNLOCKED_PATHS:
31
+ await self.app(scope, receive, send)
32
+ return
33
+ async with self.lock:
34
+ await self.app(scope, receive, send)
@@ -0,0 +1,100 @@
1
+ from pathlib import Path
2
+
3
+ from doorstop.core import exporter, importer, publisher
4
+ from fastapi import APIRouter, Depends
5
+
6
+ from doorstop_server.deps import get_tree
7
+ from doorstop_server.errors import DoorstopApiError
8
+ from doorstop_server.schemas import (
9
+ AddItemRequest,
10
+ CreateDocumentRequest,
11
+ DocumentResponse,
12
+ ExportRequest,
13
+ ExportResponse,
14
+ ImportRequest,
15
+ ItemResponse,
16
+ PublishRequest,
17
+ PublishResponse,
18
+ ReorderIndexResponse,
19
+ ReorderRequest,
20
+ ReorderResponse,
21
+ )
22
+
23
+ router = APIRouter(prefix="/documents")
24
+
25
+ _EXPORT_EXTENSIONS = {"yaml": ".yml", "csv": ".csv", "tsv": ".tsv", "xlsx": ".xlsx"}
26
+ _PUBLISH_EXTENSIONS = {"markdown": ".md", "html": ".html", "latex": ".tex"}
27
+
28
+
29
+ @router.post("", response_model=DocumentResponse)
30
+ async def create_document(body: CreateDocumentRequest, tree=Depends(get_tree)) -> DocumentResponse:
31
+ document = tree.create_document(
32
+ body.path,
33
+ body.prefix,
34
+ sep=body.separator,
35
+ digits=body.digits,
36
+ parent=body.parentPrefix,
37
+ itemformat=body.itemFormat,
38
+ )
39
+ return DocumentResponse(prefix=str(document.prefix), path=document.path)
40
+
41
+
42
+ @router.post("/{prefix}/items", response_model=ItemResponse)
43
+ async def add_item(prefix: str, body: AddItemRequest, tree=Depends(get_tree)) -> ItemResponse:
44
+ document = tree.find_document(prefix)
45
+ item = document.add_item(level=body.level)
46
+ return ItemResponse(uid=str(item.uid), path=item.path, level=str(item.level))
47
+
48
+
49
+ @router.post("/{prefix}/reorder/index", response_model=ReorderIndexResponse)
50
+ async def ensure_reorder_index(prefix: str, tree=Depends(get_tree)) -> ReorderIndexResponse:
51
+ document = tree.find_document(prefix)
52
+ if not document.index:
53
+ document.index = True
54
+ return ReorderIndexResponse(prefix=prefix, indexPath=document.index)
55
+
56
+
57
+ @router.delete("/{prefix}/reorder/index", status_code=204)
58
+ async def discard_reorder_index(prefix: str, tree=Depends(get_tree)) -> None:
59
+ document = tree.find_document(prefix)
60
+ del document.index
61
+
62
+
63
+ @router.post("/{prefix}/reorder", response_model=ReorderResponse)
64
+ async def reorder_document(prefix: str, body: ReorderRequest, tree=Depends(get_tree)) -> ReorderResponse:
65
+ document = tree.find_document(prefix)
66
+ if body.mode == "auto":
67
+ document.reorder(manual=False)
68
+ else:
69
+ if not document.index:
70
+ raise DoorstopApiError(
71
+ 409,
72
+ "NO_REORDER_INDEX",
73
+ "No index file to reorder from -- call POST /documents/{prefix}/reorder/index first.",
74
+ )
75
+ document.reorder(manual=True, automatic=False)
76
+ return ReorderResponse(prefix=prefix, mode=body.mode)
77
+
78
+
79
+ @router.post("/{prefix}/import", response_model=DocumentResponse)
80
+ async def import_into_document(prefix: str, body: ImportRequest, tree=Depends(get_tree)) -> DocumentResponse:
81
+ document = tree.find_document(prefix)
82
+ ext = Path(body.sourcePath).suffix
83
+ importer.import_file(body.sourcePath, document, ext)
84
+ return DocumentResponse(prefix=str(document.prefix), path=document.path)
85
+
86
+
87
+ @router.post("/{prefix}/export", response_model=ExportResponse)
88
+ async def export_document(prefix: str, body: ExportRequest, tree=Depends(get_tree)) -> ExportResponse:
89
+ document = tree.find_document(prefix)
90
+ ext = _EXPORT_EXTENSIONS[body.format]
91
+ path = exporter.export(document, body.destinationPath, ext=ext)
92
+ return ExportResponse(path=path or body.destinationPath)
93
+
94
+
95
+ @router.post("/{prefix}/publish", response_model=PublishResponse)
96
+ async def publish_document(prefix: str, body: PublishRequest, tree=Depends(get_tree)) -> PublishResponse:
97
+ document = tree.find_document(prefix)
98
+ ext = _PUBLISH_EXTENSIONS[body.format]
99
+ path = publisher.publish(document, body.destinationPath, ext=ext)
100
+ return PublishResponse(path=path or body.destinationPath)
@@ -0,0 +1,11 @@
1
+ from fastapi import APIRouter, Request
2
+
3
+ from doorstop_server.schemas import HealthResponse
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.get("/health", response_model=HealthResponse)
9
+ async def health(request: Request) -> HealthResponse:
10
+ settings = request.app.state.settings
11
+ return HealthResponse(status="ok", projectRoot=settings.project_root)
@@ -0,0 +1,18 @@
1
+ from fastapi import APIRouter, Depends, Response
2
+
3
+ from doorstop_server.deps import get_tree
4
+ from doorstop_server.schemas import LinkRequest, LinkResponse
5
+
6
+ router = APIRouter(prefix="/items")
7
+
8
+
9
+ @router.post("/{child_uid}/links", response_model=LinkResponse)
10
+ async def link_item(child_uid: str, body: LinkRequest, tree=Depends(get_tree)) -> LinkResponse:
11
+ child, parent = tree.link_items(child_uid, body.parentUid)
12
+ return LinkResponse(child=str(child.uid), parent=str(parent.uid))
13
+
14
+
15
+ @router.delete("/{child_uid}/links/{parent_uid}", status_code=204)
16
+ async def unlink_item(child_uid: str, parent_uid: str, tree=Depends(get_tree)) -> Response:
17
+ tree.unlink_items(child_uid, parent_uid)
18
+ return Response(status_code=204)
@@ -0,0 +1,41 @@
1
+ from typing import Iterator, Optional
2
+
3
+ from doorstop.core.item import Item
4
+ from doorstop.core.tree import Tree
5
+ from fastapi import APIRouter, Depends
6
+
7
+ from doorstop_server.deps import get_tree
8
+ from doorstop_server.errors import DoorstopApiError
9
+ from doorstop_server.schemas import ReviewClearRequest
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ def _iter_items(tree: Tree, scope: str, target: Optional[str]) -> Iterator[Item]:
15
+ """Resolve {scope, target} to items, mirroring doorstop's own CLI disambiguation."""
16
+ if scope == "all":
17
+ for document in tree:
18
+ yield from document
19
+ return
20
+ if not target:
21
+ raise DoorstopApiError(422, "TARGET_REQUIRED", "target is required unless scope is 'all'.")
22
+ if scope == "document":
23
+ document = tree.find_document(target)
24
+ yield from document
25
+ return
26
+ yield tree.find_item(target)
27
+
28
+
29
+ @router.post("/review", status_code=204)
30
+ async def review_items(body: ReviewClearRequest, tree=Depends(get_tree)) -> None:
31
+ for item in _iter_items(tree, body.scope, body.target):
32
+ item.review()
33
+
34
+
35
+ @router.post("/clear", status_code=204)
36
+ async def clear_items(body: ReviewClearRequest, tree=Depends(get_tree)) -> None:
37
+ if body.parents:
38
+ for parent_uid in body.parents:
39
+ tree.find_item(parent_uid)
40
+ for item in _iter_items(tree, body.scope, body.target):
41
+ item.clear(parents=body.parents)
@@ -0,0 +1,53 @@
1
+ from doorstop.common import DoorstopError
2
+ from fastapi import APIRouter, Depends
3
+
4
+ from doorstop_server.deps import get_tree
5
+ from doorstop_server.schemas import DocumentNode, ItemNode, LinkInfo, TreeResponse
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ def _item_links(item, tree) -> list[LinkInfo]:
11
+ links = []
12
+ for uid in item.links:
13
+ try:
14
+ parent = tree.find_item(uid)
15
+ suspect = uid.stamp != parent.stamp()
16
+ except DoorstopError:
17
+ # Dangling/renamed parent UID - treat as suspect rather than failing the request.
18
+ suspect = True
19
+ links.append(LinkInfo(uid=str(uid), suspect=suspect))
20
+ return links
21
+
22
+
23
+ @router.get("/tree", response_model=TreeResponse)
24
+ async def get_tree_structure(tree=Depends(get_tree)) -> TreeResponse:
25
+ tree.load()
26
+ documents = [
27
+ DocumentNode(
28
+ prefix=str(document.prefix),
29
+ markerPath=document.config,
30
+ parentPrefix=document.parent or None,
31
+ digits=document.digits,
32
+ separator=document.sep,
33
+ itemFormat=str(document.itemformat) if document.itemformat else None,
34
+ items=[
35
+ ItemNode(
36
+ uid=str(item.uid),
37
+ path=item.path,
38
+ level=str(item.level),
39
+ header=str(item.header) if item.header else None,
40
+ text=str(item.text) if item.text else None,
41
+ active=item.active,
42
+ normative=item.normative,
43
+ derived=item.derived,
44
+ reviewed=item.reviewed,
45
+ cleared=item.cleared,
46
+ links=_item_links(item, tree),
47
+ )
48
+ for item in document
49
+ ],
50
+ )
51
+ for document in tree
52
+ ]
53
+ return TreeResponse(documents=documents)