docmost-cli 0.4.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.
- docmost_cli/__init__.py +5 -0
- docmost_cli/__main__.py +18 -0
- docmost_cli/api/__init__.py +5 -0
- docmost_cli/api/attachments.py +30 -0
- docmost_cli/api/auth.py +202 -0
- docmost_cli/api/client.py +296 -0
- docmost_cli/api/comments.py +103 -0
- docmost_cli/api/pages.py +530 -0
- docmost_cli/api/pagination.py +94 -0
- docmost_cli/api/search.py +40 -0
- docmost_cli/api/spaces.py +141 -0
- docmost_cli/api/users.py +25 -0
- docmost_cli/api/workspace.py +43 -0
- docmost_cli/cli/__init__.py +3 -0
- docmost_cli/cli/attachment.py +30 -0
- docmost_cli/cli/comment.py +83 -0
- docmost_cli/cli/config_cmd.py +143 -0
- docmost_cli/cli/main.py +133 -0
- docmost_cli/cli/page.py +382 -0
- docmost_cli/cli/search.py +33 -0
- docmost_cli/cli/space.py +57 -0
- docmost_cli/cli/sync_cmd.py +122 -0
- docmost_cli/cli/user.py +25 -0
- docmost_cli/cli/workspace.py +40 -0
- docmost_cli/config/__init__.py +23 -0
- docmost_cli/config/settings.py +23 -0
- docmost_cli/config/store.py +160 -0
- docmost_cli/convert/__init__.py +3 -0
- docmost_cli/convert/prosemirror_to_md.py +300 -0
- docmost_cli/models/__init__.py +3 -0
- docmost_cli/models/common.py +3 -0
- docmost_cli/output/__init__.py +17 -0
- docmost_cli/output/formatter.py +85 -0
- docmost_cli/output/tree.py +66 -0
- docmost_cli/py.typed +0 -0
- docmost_cli/sync/__init__.py +57 -0
- docmost_cli/sync/diff.py +156 -0
- docmost_cli/sync/frontmatter.py +152 -0
- docmost_cli/sync/manifest.py +195 -0
- docmost_cli/sync/pull.py +158 -0
- docmost_cli/sync/push.py +374 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-attachment.1 +57 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-comment.1 +92 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-config.1 +127 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-page.1 +412 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-search.1 +90 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-space.1 +111 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-sync.1 +206 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-user.1 +39 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-workspace.1 +68 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli.1 +301 -0
- docmost_cli-0.4.0.dist-info/METADATA +241 -0
- docmost_cli-0.4.0.dist-info/RECORD +56 -0
- docmost_cli-0.4.0.dist-info/WHEEL +4 -0
- docmost_cli-0.4.0.dist-info/entry_points.txt +2 -0
- docmost_cli-0.4.0.dist-info/licenses/LICENSE +661 -0
docmost_cli/__init__.py
ADDED
docmost_cli/__main__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Entry point for `python -m docmost_cli`."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
# Fix Windows console encoding: enable UTF-8 so Rich box-drawing chars
|
|
6
|
+
# and emoji content don't crash with cp1252. This runs before cli/main.py
|
|
7
|
+
# is imported. See also _ensure_utf8_stdio() in cli/main.py (for the
|
|
8
|
+
# pyproject scripts entry point which bypasses __main__.py).
|
|
9
|
+
if sys.platform == "win32":
|
|
10
|
+
try:
|
|
11
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
12
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
13
|
+
except (AttributeError, ValueError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
from docmost_cli.cli.main import app
|
|
17
|
+
|
|
18
|
+
app()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Attachment API methods."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from docmost_cli.api.client import DocmostClient
|
|
6
|
+
from docmost_cli.api.pagination import build_body
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"search_attachments",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def search_attachments(
|
|
14
|
+
client: DocmostClient,
|
|
15
|
+
query: str,
|
|
16
|
+
*,
|
|
17
|
+
space_id: str | None = None,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
"""Search attachments by query string.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
client: Authenticated Docmost client.
|
|
23
|
+
query: Search query string.
|
|
24
|
+
space_id: Optional space UUID to scope the search.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Raw API response dict with matching attachments.
|
|
28
|
+
"""
|
|
29
|
+
body = build_body({"query": query}, spaceId=space_id)
|
|
30
|
+
return client.post("/attachments/search", json=body)
|
docmost_cli/api/auth.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Authentication strategies for Docmost API.
|
|
2
|
+
|
|
3
|
+
Supports two auth modes:
|
|
4
|
+
- API key (Enterprise): Bearer token in Authorization header
|
|
5
|
+
- Session (Community/AGPL): POST /api/auth/login, cache JWT
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from docmost_cli.config.settings import DocmostSettings
|
|
16
|
+
from docmost_cli.config.store import get_cache_dir
|
|
17
|
+
|
|
18
|
+
__all__ = ["ApiKeyAuth", "AuthError", "AuthStrategy", "SessionAuth", "create_auth"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthError(Exception):
|
|
22
|
+
"""Raised when authentication fails."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthStrategy(ABC):
|
|
26
|
+
"""Base class for authentication strategies."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def apply(self, request: httpx.Request) -> None:
|
|
30
|
+
"""Add auth headers to a request."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def can_retry(self) -> bool:
|
|
34
|
+
"""Whether this strategy supports re-authentication on 401."""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def refresh(self, client: httpx.Client) -> None:
|
|
38
|
+
"""Re-authenticate. Called on 401 if can_retry() is True."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ApiKeyAuth(AuthStrategy):
|
|
42
|
+
"""Bearer token authentication for Enterprise edition."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, api_key: str) -> None:
|
|
45
|
+
self._api_key = api_key
|
|
46
|
+
|
|
47
|
+
def apply(self, request: httpx.Request) -> None:
|
|
48
|
+
"""Set Authorization: Bearer header."""
|
|
49
|
+
request.headers["Authorization"] = f"Bearer {self._api_key}"
|
|
50
|
+
|
|
51
|
+
def can_retry(self) -> bool:
|
|
52
|
+
"""API key is static; no retry possible."""
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
def refresh(self, client: httpx.Client) -> None:
|
|
56
|
+
"""Not supported for API key auth."""
|
|
57
|
+
raise AuthError("API key authentication failed. Check your API key.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SessionAuth(AuthStrategy):
|
|
61
|
+
"""Session-based authentication for Community/AGPL edition.
|
|
62
|
+
|
|
63
|
+
Authenticates via POST /api/auth/login, extracts JWT from the response,
|
|
64
|
+
and caches it in ~/.cache/docmost-cli/session.json.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, url: str, email: str, password: str) -> None:
|
|
68
|
+
self._url = url.rstrip("/")
|
|
69
|
+
self._email = email
|
|
70
|
+
self._password = password
|
|
71
|
+
self._token: str | None = None
|
|
72
|
+
self._load_cached_token()
|
|
73
|
+
|
|
74
|
+
def _cache_path(self) -> Path:
|
|
75
|
+
"""Return the path to the session cache file."""
|
|
76
|
+
return get_cache_dir() / "session.json"
|
|
77
|
+
|
|
78
|
+
def _load_cached_token(self) -> None:
|
|
79
|
+
"""Try to load a cached JWT from the cache file."""
|
|
80
|
+
cache = self._cache_path()
|
|
81
|
+
if not cache.exists():
|
|
82
|
+
return
|
|
83
|
+
try:
|
|
84
|
+
data = json.loads(cache.read_text())
|
|
85
|
+
if data.get("url") == self._url and data.get("email") == self._email:
|
|
86
|
+
self._token = data.get("token")
|
|
87
|
+
except (json.JSONDecodeError, KeyError):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
def _save_cached_token(self) -> None:
|
|
91
|
+
"""Save the current JWT to the cache file."""
|
|
92
|
+
if not self._token:
|
|
93
|
+
return
|
|
94
|
+
cache = self._cache_path()
|
|
95
|
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
data = {
|
|
97
|
+
"token": self._token,
|
|
98
|
+
"url": self._url,
|
|
99
|
+
"email": self._email,
|
|
100
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
101
|
+
}
|
|
102
|
+
cache.write_text(json.dumps(data, indent=2))
|
|
103
|
+
import contextlib
|
|
104
|
+
|
|
105
|
+
with contextlib.suppress(OSError):
|
|
106
|
+
cache.chmod(0o600) # Owner read/write only
|
|
107
|
+
|
|
108
|
+
def apply(self, request: httpx.Request) -> None:
|
|
109
|
+
"""Set Authorization header from cached token."""
|
|
110
|
+
if self._token:
|
|
111
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
112
|
+
|
|
113
|
+
def can_retry(self) -> bool:
|
|
114
|
+
"""Session auth supports re-authentication."""
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
def refresh(self, client: httpx.Client) -> None:
|
|
118
|
+
"""POST /api/auth/login to get a fresh JWT.
|
|
119
|
+
|
|
120
|
+
Extracts the token from the response JSON body or Set-Cookie header.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
client: The httpx client to use for the login request.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
AuthError: If login fails.
|
|
127
|
+
"""
|
|
128
|
+
login_url = f"{self._url}/api/auth/login"
|
|
129
|
+
try:
|
|
130
|
+
response = client.post(
|
|
131
|
+
login_url,
|
|
132
|
+
json={"email": self._email, "password": self._password},
|
|
133
|
+
)
|
|
134
|
+
except httpx.HTTPError as exc:
|
|
135
|
+
raise AuthError(f"Failed to connect for authentication: {exc}") from exc
|
|
136
|
+
|
|
137
|
+
if response.status_code != 200:
|
|
138
|
+
raise AuthError(
|
|
139
|
+
f"Authentication failed (HTTP {response.status_code}). "
|
|
140
|
+
"Check your email and password."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Try to extract token from response cookies (Docmost uses authToken cookie)
|
|
144
|
+
token: str | None = None
|
|
145
|
+
cookies = response.cookies
|
|
146
|
+
if "authToken" in cookies:
|
|
147
|
+
token = cookies["authToken"]
|
|
148
|
+
elif "token" in cookies:
|
|
149
|
+
token = cookies["token"]
|
|
150
|
+
|
|
151
|
+
# Fallback: try response JSON body
|
|
152
|
+
if not token:
|
|
153
|
+
try:
|
|
154
|
+
body = response.json()
|
|
155
|
+
token = body.get("token") or body.get("authToken")
|
|
156
|
+
except (json.JSONDecodeError, AttributeError):
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
# Fallback: parse Set-Cookie header manually
|
|
160
|
+
if not token:
|
|
161
|
+
for cookie_header in response.headers.get_list("set-cookie"):
|
|
162
|
+
if "authToken=" in cookie_header:
|
|
163
|
+
token = cookie_header.split("authToken=")[1].split(";")[0]
|
|
164
|
+
break
|
|
165
|
+
|
|
166
|
+
if not token:
|
|
167
|
+
raise AuthError(
|
|
168
|
+
"Authentication succeeded but no token found in response. "
|
|
169
|
+
"This may be an unsupported Docmost version."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
self._token = token
|
|
173
|
+
self._save_cached_token()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def create_auth(settings: DocmostSettings) -> AuthStrategy:
|
|
177
|
+
"""Create the appropriate auth strategy based on settings.
|
|
178
|
+
|
|
179
|
+
Priority: api_key > email+password > error.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
settings: The resolved settings.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
An AuthStrategy instance.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
AuthError: If no credentials are configured.
|
|
189
|
+
"""
|
|
190
|
+
if settings.api_key:
|
|
191
|
+
return ApiKeyAuth(settings.api_key)
|
|
192
|
+
|
|
193
|
+
if settings.email and settings.password:
|
|
194
|
+
if not settings.url:
|
|
195
|
+
raise AuthError("URL is required for session authentication.")
|
|
196
|
+
return SessionAuth(settings.url, settings.email, settings.password)
|
|
197
|
+
|
|
198
|
+
raise AuthError(
|
|
199
|
+
"No authentication configured. "
|
|
200
|
+
"Set DOCMOST_API_KEY or DOCMOST_EMAIL + DOCMOST_PASSWORD. "
|
|
201
|
+
"Run 'docmost-cli config init' for interactive setup."
|
|
202
|
+
)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""DocmostClient: central HTTP client with auth, retry, and error handling.
|
|
2
|
+
|
|
3
|
+
All API calls go through this client. It handles:
|
|
4
|
+
- Auth header injection via AuthStrategy
|
|
5
|
+
- 401 retry (session auth re-authentication)
|
|
6
|
+
- Exponential backoff retry for transient errors (429, 5xx)
|
|
7
|
+
- HTTP error translation to user-friendly messages with exit codes
|
|
8
|
+
- Optional verbose debug logging
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from docmost_cli.api.auth import AuthError, AuthStrategy, create_auth
|
|
19
|
+
from docmost_cli.config.settings import DocmostSettings
|
|
20
|
+
from docmost_cli.output.formatter import print_error
|
|
21
|
+
|
|
22
|
+
__all__ = ["DocmostClient"]
|
|
23
|
+
|
|
24
|
+
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
25
|
+
_MAX_RETRIES = 3
|
|
26
|
+
_BASE_BACKOFF = 1.0
|
|
27
|
+
_BACKOFF_FACTOR = 2.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DocmostClient:
|
|
31
|
+
"""HTTP client for the Docmost API.
|
|
32
|
+
|
|
33
|
+
Uses httpx.Client for connection pooling. Provides authenticated
|
|
34
|
+
request methods with automatic error handling and retry logic.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, settings: DocmostSettings, *, verbose: bool = False) -> None:
|
|
38
|
+
if not settings.url:
|
|
39
|
+
print_error(
|
|
40
|
+
"No Docmost URL configured. Run 'docmost-cli config init' or set DOCMOST_URL.",
|
|
41
|
+
exit_code=1,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
self._settings = settings
|
|
45
|
+
self._base_url = settings.url.rstrip("/") # type: ignore[union-attr]
|
|
46
|
+
self._auth: AuthStrategy = create_auth(settings)
|
|
47
|
+
self._http = httpx.Client(timeout=30.0)
|
|
48
|
+
self._verbose = verbose
|
|
49
|
+
|
|
50
|
+
# Set up logging
|
|
51
|
+
self._log = logging.getLogger("docmost_cli")
|
|
52
|
+
if verbose and not self._log.handlers:
|
|
53
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
54
|
+
handler.setFormatter(logging.Formatter("[docmost] %(message)s"))
|
|
55
|
+
self._log.addHandler(handler)
|
|
56
|
+
self._log.setLevel(logging.DEBUG)
|
|
57
|
+
|
|
58
|
+
def _send_with_retry(self, request: httpx.Request) -> httpx.Response:
|
|
59
|
+
"""Send a request with auth, retry on 401/429/5xx, and error handling.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
request: The prepared httpx request.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The HTTP response (success only; errors raise SystemExit).
|
|
66
|
+
"""
|
|
67
|
+
self._auth.apply(request)
|
|
68
|
+
|
|
69
|
+
if self._verbose:
|
|
70
|
+
self._log.debug("%s %s", request.method, request.url)
|
|
71
|
+
|
|
72
|
+
start = time.monotonic()
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
response = self._http.send(request)
|
|
76
|
+
except httpx.ConnectError:
|
|
77
|
+
print_error(
|
|
78
|
+
f"Cannot connect to {self._base_url}. Check the URL and your network.",
|
|
79
|
+
exit_code=1,
|
|
80
|
+
)
|
|
81
|
+
except httpx.TimeoutException:
|
|
82
|
+
print_error(
|
|
83
|
+
f"Request timed out connecting to {self._base_url}.",
|
|
84
|
+
exit_code=1,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if self._verbose:
|
|
88
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
89
|
+
self._log.debug(" → %s (%dms)", response.status_code, elapsed)
|
|
90
|
+
|
|
91
|
+
# Handle 401 retry for session auth
|
|
92
|
+
if response.status_code == 401 and self._auth.can_retry():
|
|
93
|
+
try:
|
|
94
|
+
self._auth.refresh(self._http)
|
|
95
|
+
except AuthError as exc:
|
|
96
|
+
print_error(str(exc), exit_code=3)
|
|
97
|
+
|
|
98
|
+
request = self._http.build_request(
|
|
99
|
+
request.method,
|
|
100
|
+
str(request.url),
|
|
101
|
+
headers=dict(request.headers),
|
|
102
|
+
content=request.content,
|
|
103
|
+
)
|
|
104
|
+
self._auth.apply(request)
|
|
105
|
+
try:
|
|
106
|
+
response = self._http.send(request)
|
|
107
|
+
except httpx.HTTPError:
|
|
108
|
+
print_error("Request failed after re-authentication.", exit_code=1)
|
|
109
|
+
|
|
110
|
+
# Exponential backoff retry for transient errors
|
|
111
|
+
for attempt in range(_MAX_RETRIES):
|
|
112
|
+
if response.status_code not in _RETRYABLE_STATUS:
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
# Parse Retry-After header for 429
|
|
116
|
+
wait = _BASE_BACKOFF * (_BACKOFF_FACTOR**attempt)
|
|
117
|
+
if response.status_code == 429:
|
|
118
|
+
retry_after = response.headers.get("Retry-After")
|
|
119
|
+
if retry_after and retry_after.isdigit():
|
|
120
|
+
wait = min(float(retry_after), 60.0)
|
|
121
|
+
|
|
122
|
+
if self._verbose:
|
|
123
|
+
self._log.debug(
|
|
124
|
+
" Retrying in %.1fs (attempt %d/%d)...",
|
|
125
|
+
wait,
|
|
126
|
+
attempt + 1,
|
|
127
|
+
_MAX_RETRIES,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
time.sleep(wait)
|
|
131
|
+
|
|
132
|
+
request = self._http.build_request(
|
|
133
|
+
request.method,
|
|
134
|
+
str(request.url),
|
|
135
|
+
headers=dict(request.headers),
|
|
136
|
+
content=request.content,
|
|
137
|
+
)
|
|
138
|
+
self._auth.apply(request)
|
|
139
|
+
try:
|
|
140
|
+
response = self._http.send(request)
|
|
141
|
+
except httpx.HTTPError:
|
|
142
|
+
if attempt == _MAX_RETRIES - 1:
|
|
143
|
+
print_error(
|
|
144
|
+
f"Request failed after {_MAX_RETRIES} retries.",
|
|
145
|
+
exit_code=1,
|
|
146
|
+
)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if self._verbose:
|
|
150
|
+
self._log.debug(" → %s (retry)", response.status_code)
|
|
151
|
+
|
|
152
|
+
# Translate HTTP errors
|
|
153
|
+
self._handle_error(response)
|
|
154
|
+
|
|
155
|
+
return response
|
|
156
|
+
|
|
157
|
+
def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
158
|
+
"""Make an authenticated API request with error handling.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
method: HTTP method (GET, POST, etc.).
|
|
162
|
+
path: API path relative to /api/ (e.g., "/pages/info").
|
|
163
|
+
**kwargs: Additional arguments passed to httpx (json, params, etc.).
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Parsed JSON response body.
|
|
167
|
+
"""
|
|
168
|
+
url = f"{self._base_url}/api{path}"
|
|
169
|
+
request = self._http.build_request(method, url, **kwargs)
|
|
170
|
+
response = self._send_with_retry(request)
|
|
171
|
+
return response.json()
|
|
172
|
+
|
|
173
|
+
def post(self, path: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
174
|
+
"""Convenience method for POST requests.
|
|
175
|
+
|
|
176
|
+
Most Docmost API endpoints use POST.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
path: API path relative to /api/.
|
|
180
|
+
json: JSON body to send.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Parsed JSON response body.
|
|
184
|
+
"""
|
|
185
|
+
return self.request("POST", path, json=json)
|
|
186
|
+
|
|
187
|
+
def post_multipart(
|
|
188
|
+
self,
|
|
189
|
+
path: str,
|
|
190
|
+
data: dict[str, str] | None = None,
|
|
191
|
+
files: dict[str, Any] | None = None,
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
"""POST with multipart/form-data for file uploads.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
path: API path relative to /api/.
|
|
197
|
+
data: Form fields.
|
|
198
|
+
files: File fields (httpx files format).
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Parsed JSON response body.
|
|
202
|
+
"""
|
|
203
|
+
url = f"{self._base_url}/api{path}"
|
|
204
|
+
request = self._http.build_request("POST", url, data=data, files=files)
|
|
205
|
+
response = self._send_with_retry(request)
|
|
206
|
+
return response.json()
|
|
207
|
+
|
|
208
|
+
def post_raw(
|
|
209
|
+
self, path: str, json: dict[str, Any] | None = None, *, raise_on_error: bool = True
|
|
210
|
+
) -> httpx.Response:
|
|
211
|
+
"""POST request returning raw httpx.Response.
|
|
212
|
+
|
|
213
|
+
Use for binary/non-JSON responses or silent probes.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
path: API path relative to /api/.
|
|
217
|
+
json: JSON body to send.
|
|
218
|
+
raise_on_error: If False, skip error handling (for endpoint probes).
|
|
219
|
+
"""
|
|
220
|
+
url = f"{self._base_url}/api{path}"
|
|
221
|
+
request = self._http.build_request("POST", url, json=json)
|
|
222
|
+
self._auth.apply(request)
|
|
223
|
+
try:
|
|
224
|
+
response = self._http.send(request)
|
|
225
|
+
except httpx.HTTPError:
|
|
226
|
+
if raise_on_error:
|
|
227
|
+
print_error("Request failed.", exit_code=1)
|
|
228
|
+
return httpx.Response(status_code=0) # Sentinel for failed probe
|
|
229
|
+
if raise_on_error:
|
|
230
|
+
self._handle_error(response)
|
|
231
|
+
return response
|
|
232
|
+
|
|
233
|
+
def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
234
|
+
"""Convenience method for GET requests.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
path: API path relative to /api/.
|
|
238
|
+
**kwargs: Additional arguments (params, etc.).
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Parsed JSON response body.
|
|
242
|
+
"""
|
|
243
|
+
return self.request("GET", path, **kwargs)
|
|
244
|
+
|
|
245
|
+
def close(self) -> None:
|
|
246
|
+
"""Close the underlying HTTP client."""
|
|
247
|
+
self._http.close()
|
|
248
|
+
|
|
249
|
+
def __enter__(self) -> "DocmostClient":
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
def __exit__(self, *args: Any) -> None:
|
|
253
|
+
self.close()
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def _handle_error(response: httpx.Response) -> None:
|
|
257
|
+
"""Translate HTTP error responses to user-friendly messages.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
response: The HTTP response to check.
|
|
261
|
+
"""
|
|
262
|
+
if response.is_success:
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
status = response.status_code
|
|
266
|
+
|
|
267
|
+
if status == 401:
|
|
268
|
+
print_error(
|
|
269
|
+
"Authentication failed. Run 'docmost-cli config test' to verify.",
|
|
270
|
+
exit_code=3,
|
|
271
|
+
)
|
|
272
|
+
elif status == 403:
|
|
273
|
+
print_error("Permission denied.", exit_code=1)
|
|
274
|
+
elif status == 404:
|
|
275
|
+
print_error(
|
|
276
|
+
"Resource not found. Check the ID or slug.",
|
|
277
|
+
exit_code=4,
|
|
278
|
+
)
|
|
279
|
+
elif status == 422:
|
|
280
|
+
try:
|
|
281
|
+
detail = response.json().get("message", "Validation error")
|
|
282
|
+
except (ValueError, AttributeError):
|
|
283
|
+
detail = "Validation error"
|
|
284
|
+
print_error(f"Validation error: {detail}", exit_code=1)
|
|
285
|
+
elif status == 429:
|
|
286
|
+
print_error("Rate limited. Try again later.", exit_code=1)
|
|
287
|
+
elif status >= 500:
|
|
288
|
+
print_error(
|
|
289
|
+
f"Server error ({status}). Check Docmost logs.",
|
|
290
|
+
exit_code=1,
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
print_error(
|
|
294
|
+
f"Unexpected error (HTTP {status}).",
|
|
295
|
+
exit_code=1,
|
|
296
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Comment API methods."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from docmost_cli.api.client import DocmostClient
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"create_comment",
|
|
10
|
+
"list_comments",
|
|
11
|
+
"update_comment",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _wrap_text_as_prosemirror(text: str) -> dict[str, Any]:
|
|
16
|
+
"""Wrap plain text into a minimal ProseMirror document.
|
|
17
|
+
|
|
18
|
+
Creates the structure Docmost expects for comment content.
|
|
19
|
+
Multi-line text is split into separate paragraphs.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
text: Plain text content.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
ProseMirror document dict.
|
|
26
|
+
"""
|
|
27
|
+
paragraphs = []
|
|
28
|
+
for line in text.split("\n"):
|
|
29
|
+
if line.strip():
|
|
30
|
+
paragraphs.append(
|
|
31
|
+
{
|
|
32
|
+
"type": "paragraph",
|
|
33
|
+
"content": [{"type": "text", "text": line}],
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
else:
|
|
37
|
+
paragraphs.append({"type": "paragraph"})
|
|
38
|
+
|
|
39
|
+
if not paragraphs:
|
|
40
|
+
paragraphs = [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]
|
|
41
|
+
|
|
42
|
+
return {"type": "doc", "content": paragraphs}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def list_comments(client: DocmostClient, page_id: str) -> dict[str, Any]:
|
|
46
|
+
"""List all comments on a page.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
client: Authenticated Docmost client.
|
|
50
|
+
page_id: Page UUID.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Raw API response dict.
|
|
54
|
+
"""
|
|
55
|
+
return client.post("/comments", json={"pageId": page_id})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_comment(
|
|
59
|
+
client: DocmostClient,
|
|
60
|
+
*,
|
|
61
|
+
page_id: str,
|
|
62
|
+
content: str,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
"""Create a comment on a page.
|
|
65
|
+
|
|
66
|
+
Content is wrapped in ProseMirror JSON format as required by the API.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
client: Authenticated Docmost client.
|
|
70
|
+
page_id: Page UUID.
|
|
71
|
+
content: Plain text comment content.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Raw API response dict.
|
|
75
|
+
"""
|
|
76
|
+
pm_content = _wrap_text_as_prosemirror(content)
|
|
77
|
+
return client.post(
|
|
78
|
+
"/comments/create",
|
|
79
|
+
json={"pageId": page_id, "content": json.dumps(pm_content)},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def update_comment(
|
|
84
|
+
client: DocmostClient,
|
|
85
|
+
*,
|
|
86
|
+
comment_id: str,
|
|
87
|
+
content: str,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""Update an existing comment.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
client: Authenticated Docmost client.
|
|
93
|
+
comment_id: Comment UUID.
|
|
94
|
+
content: New plain text content.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Raw API response dict.
|
|
98
|
+
"""
|
|
99
|
+
pm_content = _wrap_text_as_prosemirror(content)
|
|
100
|
+
return client.post(
|
|
101
|
+
"/comments/update",
|
|
102
|
+
json={"commentId": comment_id, "content": json.dumps(pm_content)},
|
|
103
|
+
)
|