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.
- doorstop_vscode_server-0.1.0/LICENSE +21 -0
- doorstop_vscode_server-0.1.0/PKG-INFO +63 -0
- doorstop_vscode_server-0.1.0/README.md +38 -0
- doorstop_vscode_server-0.1.0/pyproject.toml +49 -0
- doorstop_vscode_server-0.1.0/setup.cfg +4 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/__init__.py +1 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/__main__.py +4 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/app.py +22 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/cli.py +27 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/config.py +8 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/deps.py +13 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/doorstop_tree.py +20 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/errors.py +34 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/lock.py +34 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/routers/__init__.py +0 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/routers/documents.py +100 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/routers/health.py +11 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/routers/items.py +18 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/routers/review.py +41 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/routers/tree.py +53 -0
- doorstop_vscode_server-0.1.0/src/doorstop_server/schemas.py +116 -0
- doorstop_vscode_server-0.1.0/src/doorstop_vscode_server.egg-info/PKG-INFO +63 -0
- doorstop_vscode_server-0.1.0/src/doorstop_vscode_server.egg-info/SOURCES.txt +32 -0
- doorstop_vscode_server-0.1.0/src/doorstop_vscode_server.egg-info/dependency_links.txt +1 -0
- doorstop_vscode_server-0.1.0/src/doorstop_vscode_server.egg-info/entry_points.txt +2 -0
- doorstop_vscode_server-0.1.0/src/doorstop_vscode_server.egg-info/requires.txt +12 -0
- doorstop_vscode_server-0.1.0/src/doorstop_vscode_server.egg-info/top_level.txt +1 -0
- doorstop_vscode_server-0.1.0/tests/test_documents.py +171 -0
- doorstop_vscode_server-0.1.0/tests/test_errors.py +51 -0
- doorstop_vscode_server-0.1.0/tests/test_health.py +7 -0
- doorstop_vscode_server-0.1.0/tests/test_items.py +39 -0
- doorstop_vscode_server-0.1.0/tests/test_review.py +105 -0
- doorstop_vscode_server-0.1.0/tests/test_serialization.py +69 -0
- 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 @@
|
|
|
1
|
+
"""FastAPI server wrapping the Doorstop Python API."""
|
|
@@ -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,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)
|
|
File without changes
|
|
@@ -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)
|