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.
Files changed (56) hide show
  1. docmost_cli/__init__.py +5 -0
  2. docmost_cli/__main__.py +18 -0
  3. docmost_cli/api/__init__.py +5 -0
  4. docmost_cli/api/attachments.py +30 -0
  5. docmost_cli/api/auth.py +202 -0
  6. docmost_cli/api/client.py +296 -0
  7. docmost_cli/api/comments.py +103 -0
  8. docmost_cli/api/pages.py +530 -0
  9. docmost_cli/api/pagination.py +94 -0
  10. docmost_cli/api/search.py +40 -0
  11. docmost_cli/api/spaces.py +141 -0
  12. docmost_cli/api/users.py +25 -0
  13. docmost_cli/api/workspace.py +43 -0
  14. docmost_cli/cli/__init__.py +3 -0
  15. docmost_cli/cli/attachment.py +30 -0
  16. docmost_cli/cli/comment.py +83 -0
  17. docmost_cli/cli/config_cmd.py +143 -0
  18. docmost_cli/cli/main.py +133 -0
  19. docmost_cli/cli/page.py +382 -0
  20. docmost_cli/cli/search.py +33 -0
  21. docmost_cli/cli/space.py +57 -0
  22. docmost_cli/cli/sync_cmd.py +122 -0
  23. docmost_cli/cli/user.py +25 -0
  24. docmost_cli/cli/workspace.py +40 -0
  25. docmost_cli/config/__init__.py +23 -0
  26. docmost_cli/config/settings.py +23 -0
  27. docmost_cli/config/store.py +160 -0
  28. docmost_cli/convert/__init__.py +3 -0
  29. docmost_cli/convert/prosemirror_to_md.py +300 -0
  30. docmost_cli/models/__init__.py +3 -0
  31. docmost_cli/models/common.py +3 -0
  32. docmost_cli/output/__init__.py +17 -0
  33. docmost_cli/output/formatter.py +85 -0
  34. docmost_cli/output/tree.py +66 -0
  35. docmost_cli/py.typed +0 -0
  36. docmost_cli/sync/__init__.py +57 -0
  37. docmost_cli/sync/diff.py +156 -0
  38. docmost_cli/sync/frontmatter.py +152 -0
  39. docmost_cli/sync/manifest.py +195 -0
  40. docmost_cli/sync/pull.py +158 -0
  41. docmost_cli/sync/push.py +374 -0
  42. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-attachment.1 +57 -0
  43. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-comment.1 +92 -0
  44. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-config.1 +127 -0
  45. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-page.1 +412 -0
  46. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-search.1 +90 -0
  47. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-space.1 +111 -0
  48. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-sync.1 +206 -0
  49. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-user.1 +39 -0
  50. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-workspace.1 +68 -0
  51. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli.1 +301 -0
  52. docmost_cli-0.4.0.dist-info/METADATA +241 -0
  53. docmost_cli-0.4.0.dist-info/RECORD +56 -0
  54. docmost_cli-0.4.0.dist-info/WHEEL +4 -0
  55. docmost_cli-0.4.0.dist-info/entry_points.txt +2 -0
  56. docmost_cli-0.4.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,5 @@
1
+ """docmost-cli: CLI tool for managing Docmost wiki instances."""
2
+
3
+ __version__ = "0.4.0"
4
+
5
+ __all__ = ["__version__"]
@@ -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,5 @@
1
+ """Docmost API client and endpoint methods."""
2
+
3
+ from docmost_cli.api.client import DocmostClient
4
+
5
+ __all__ = ["DocmostClient"]
@@ -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)
@@ -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
+ )