wptui 0.1.1__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.
- wptui/__init__.py +3 -0
- wptui/__main__.py +13 -0
- wptui/api/__init__.py +25 -0
- wptui/api/client.py +411 -0
- wptui/api/dto.py +191 -0
- wptui/api/errors.py +27 -0
- wptui/app.py +61 -0
- wptui/app.tcss +276 -0
- wptui/autosave.py +83 -0
- wptui/blocks/__init__.py +12 -0
- wptui/blocks/factory.py +64 -0
- wptui/blocks/grammar.py +201 -0
- wptui/blocks/image.py +108 -0
- wptui/blocks/model.py +79 -0
- wptui/blocks/serialize.py +112 -0
- wptui/blocks/text.py +69 -0
- wptui/config.py +139 -0
- wptui/inline/__init__.py +34 -0
- wptui/inline/html_parse.py +81 -0
- wptui/inline/html_serialize.py +65 -0
- wptui/inline/markup.py +358 -0
- wptui/inline/model.py +115 -0
- wptui/keys/__init__.py +13 -0
- wptui/keys/modes.py +26 -0
- wptui/keys/vim.py +115 -0
- wptui/paths.py +48 -0
- wptui/screens/__init__.py +1 -0
- wptui/screens/connect.py +133 -0
- wptui/screens/editor.py +463 -0
- wptui/screens/post_list.py +98 -0
- wptui/screens/post_settings.py +176 -0
- wptui/widgets/__init__.py +1 -0
- wptui/widgets/canvas.py +227 -0
- wptui/widgets/confirm.py +53 -0
- wptui/widgets/image_card.py +73 -0
- wptui/widgets/image_upload.py +76 -0
- wptui/widgets/inline_area.py +219 -0
- wptui/widgets/media_picker.py +88 -0
- wptui/widgets/opaque_card.py +42 -0
- wptui/widgets/separator_card.py +22 -0
- wptui/widgets/term_picker.py +107 -0
- wptui/widgets/text_block.py +49 -0
- wptui-0.1.1.dist-info/METADATA +94 -0
- wptui-0.1.1.dist-info/RECORD +48 -0
- wptui-0.1.1.dist-info/WHEEL +5 -0
- wptui-0.1.1.dist-info/entry_points.txt +2 -0
- wptui-0.1.1.dist-info/licenses/LICENSE +674 -0
- wptui-0.1.1.dist-info/top_level.txt +1 -0
wptui/__init__.py
ADDED
wptui/__main__.py
ADDED
wptui/api/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""WordPress REST API client layer."""
|
|
2
|
+
|
|
3
|
+
from wptui.api.client import WordPressClient
|
|
4
|
+
from wptui.api.dto import MediaItem, PostDetail, PostSettings, PostSummary, Term
|
|
5
|
+
from wptui.api.errors import (
|
|
6
|
+
ApiError,
|
|
7
|
+
AuthError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
NetworkError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"WordPressClient",
|
|
15
|
+
"PostSummary",
|
|
16
|
+
"PostDetail",
|
|
17
|
+
"PostSettings",
|
|
18
|
+
"Term",
|
|
19
|
+
"MediaItem",
|
|
20
|
+
"ApiError",
|
|
21
|
+
"AuthError",
|
|
22
|
+
"ConflictError",
|
|
23
|
+
"NetworkError",
|
|
24
|
+
"NotFoundError",
|
|
25
|
+
]
|
wptui/api/client.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Async WordPress REST API client (self-hosted, Application Passwords)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from wptui.api.dto import MediaItem, PostDetail, PostSummary, PostSettings, Term
|
|
14
|
+
from wptui.api.errors import (
|
|
15
|
+
AuthError,
|
|
16
|
+
ConflictError,
|
|
17
|
+
NetworkError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
)
|
|
20
|
+
from wptui.config import SiteProfile
|
|
21
|
+
|
|
22
|
+
# Fields fetched for an editable post/page (context=edit gives the raw variants).
|
|
23
|
+
_POST_FIELDS = (
|
|
24
|
+
"id,title,content,status,modified_gmt,link,type,slug,excerpt,date,"
|
|
25
|
+
"password,categories,tags,featured_media,parent,menu_order,template"
|
|
26
|
+
)
|
|
27
|
+
_TYPE_PATH = {"post": "posts", "page": "pages"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _type_path(post_type: str) -> str:
|
|
31
|
+
"""REST collection segment for a post type (``post`` -> ``posts``)."""
|
|
32
|
+
return _TYPE_PATH.get(post_type, "posts")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_IMAGE_MIME = {
|
|
36
|
+
".png": "image/png",
|
|
37
|
+
".jpg": "image/jpeg",
|
|
38
|
+
".jpeg": "image/jpeg",
|
|
39
|
+
".gif": "image/gif",
|
|
40
|
+
".webp": "image/webp",
|
|
41
|
+
".svg": "image/svg+xml",
|
|
42
|
+
".bmp": "image/bmp",
|
|
43
|
+
".tif": "image/tiff",
|
|
44
|
+
".tiff": "image/tiff",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _guess_mime(filename: str) -> str:
|
|
49
|
+
ext = Path(filename).suffix.lower()
|
|
50
|
+
if ext in _IMAGE_MIME:
|
|
51
|
+
return _IMAGE_MIME[ext]
|
|
52
|
+
guessed, _ = mimetypes.guess_type(filename)
|
|
53
|
+
return guessed or "application/octet-stream"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WordPressClient:
|
|
57
|
+
"""Thin async wrapper over ``/wp-json/wp/v2/`` using HTTP Basic auth.
|
|
58
|
+
|
|
59
|
+
Use as an async context manager so the underlying httpx client is closed::
|
|
60
|
+
|
|
61
|
+
async with WordPressClient(profile, app_password) as wp:
|
|
62
|
+
posts = await wp.list_posts()
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
profile: SiteProfile,
|
|
68
|
+
app_password: str,
|
|
69
|
+
*,
|
|
70
|
+
timeout: float = 20.0,
|
|
71
|
+
) -> None:
|
|
72
|
+
self._profile = profile
|
|
73
|
+
# WordPress Application Passwords are shown with spaces for readability but
|
|
74
|
+
# are validated with them stripped.
|
|
75
|
+
self._client = httpx.AsyncClient(
|
|
76
|
+
base_url=profile.api_root,
|
|
77
|
+
auth=(profile.username, app_password.replace(" ", "")),
|
|
78
|
+
timeout=timeout,
|
|
79
|
+
headers={"Accept": "application/json"},
|
|
80
|
+
follow_redirects=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def __aenter__(self) -> "WordPressClient":
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(
|
|
87
|
+
self,
|
|
88
|
+
exc_type: type[BaseException] | None,
|
|
89
|
+
exc: BaseException | None,
|
|
90
|
+
tb: TracebackType | None,
|
|
91
|
+
) -> None:
|
|
92
|
+
await self.aclose()
|
|
93
|
+
|
|
94
|
+
async def aclose(self) -> None:
|
|
95
|
+
await self._client.aclose()
|
|
96
|
+
|
|
97
|
+
# -- requests -----------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
async def _send(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
100
|
+
"""Send a request, mapping transport failures to :class:`NetworkError` but NOT
|
|
101
|
+
raising for an HTTP error status — the caller inspects the response itself. Used by
|
|
102
|
+
:meth:`create_term` to read a ``term_exists`` 400 that carries the existing term id.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
return await self._client.request(method, path, **kwargs)
|
|
106
|
+
except httpx.HTTPError as err: # connect/timeout/TLS/etc.
|
|
107
|
+
raise NetworkError(str(err)) from err
|
|
108
|
+
|
|
109
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
110
|
+
response = await self._send(method, path, **kwargs)
|
|
111
|
+
_raise_for_status(response)
|
|
112
|
+
return response
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _json(response: httpx.Response) -> Any:
|
|
116
|
+
"""Decode a JSON body, mapping a non-JSON 2xx to :class:`NetworkError`.
|
|
117
|
+
|
|
118
|
+
A captive portal, security plugin, or reverse-proxy page can return HTTP 200
|
|
119
|
+
with an HTML body; without this guard the raw ``JSONDecodeError`` would escape
|
|
120
|
+
the async worker and crash the whole TUI.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
return response.json()
|
|
124
|
+
except ValueError as err:
|
|
125
|
+
raise NetworkError(
|
|
126
|
+
"The server returned a non-JSON response. Is this a WordPress REST API "
|
|
127
|
+
"endpoint, and is the site reachable without a login/proxy page?"
|
|
128
|
+
) from err
|
|
129
|
+
|
|
130
|
+
async def verify(self) -> dict[str, Any]:
|
|
131
|
+
"""Confirm credentials by fetching the authenticated user.
|
|
132
|
+
|
|
133
|
+
Returns the ``/users/me`` payload. Raises :class:`AuthError` on 401/403.
|
|
134
|
+
"""
|
|
135
|
+
response = await self._request("GET", "/users/me", params={"context": "edit"})
|
|
136
|
+
data = self._json(response)
|
|
137
|
+
if not isinstance(data, dict):
|
|
138
|
+
raise NetworkError("Unexpected response shape from /users/me.")
|
|
139
|
+
return data
|
|
140
|
+
|
|
141
|
+
async def list_posts(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
status: str = "any",
|
|
145
|
+
search: str | None = None,
|
|
146
|
+
page: int = 1,
|
|
147
|
+
per_page: int = 50,
|
|
148
|
+
) -> list[PostSummary]:
|
|
149
|
+
params: dict[str, Any] = {
|
|
150
|
+
"context": "edit",
|
|
151
|
+
"status": status,
|
|
152
|
+
"page": page,
|
|
153
|
+
"per_page": per_page,
|
|
154
|
+
"orderby": "modified",
|
|
155
|
+
"order": "desc",
|
|
156
|
+
"_fields": "id,title,status,modified_gmt,link,type",
|
|
157
|
+
}
|
|
158
|
+
if search:
|
|
159
|
+
params["search"] = search
|
|
160
|
+
response = await self._request("GET", "/posts", params=params)
|
|
161
|
+
return _parse_list(self._json(response), PostSummary.from_json, "posts")
|
|
162
|
+
|
|
163
|
+
async def get_post(self, post_id: int, post_type: str = "post") -> PostDetail:
|
|
164
|
+
"""Fetch one post/page with raw, editable content + settings (``context=edit``)."""
|
|
165
|
+
params = {"context": "edit", "_fields": _POST_FIELDS}
|
|
166
|
+
path = _type_path(post_type)
|
|
167
|
+
response = await self._request("GET", f"/{path}/{post_id}", params=params)
|
|
168
|
+
return _post_detail(self._json(response))
|
|
169
|
+
|
|
170
|
+
async def create_post(
|
|
171
|
+
self,
|
|
172
|
+
post_type: str,
|
|
173
|
+
*,
|
|
174
|
+
title_raw: str = "",
|
|
175
|
+
content_raw: str = "",
|
|
176
|
+
settings: PostSettings | None = None,
|
|
177
|
+
) -> PostDetail:
|
|
178
|
+
"""Create a new post/page and return it. No conflict pre-check (nothing exists)."""
|
|
179
|
+
payload: dict[str, Any] = {"title": title_raw, "content": content_raw}
|
|
180
|
+
if settings is not None:
|
|
181
|
+
payload.update(settings.to_payload())
|
|
182
|
+
payload.setdefault("status", "draft")
|
|
183
|
+
params = {"context": "edit", "_fields": _POST_FIELDS}
|
|
184
|
+
path = _type_path(post_type)
|
|
185
|
+
response = await self._request("POST", f"/{path}", params=params, json=payload)
|
|
186
|
+
return _post_detail(self._json(response))
|
|
187
|
+
|
|
188
|
+
async def update_post(
|
|
189
|
+
self,
|
|
190
|
+
post_id: int,
|
|
191
|
+
*,
|
|
192
|
+
content_raw: str | None = None,
|
|
193
|
+
title_raw: str | None = None,
|
|
194
|
+
settings: PostSettings | None = None,
|
|
195
|
+
expected_modified_gmt: str | None = None,
|
|
196
|
+
) -> PostDetail:
|
|
197
|
+
"""Update a post/page's content, title, and/or settings.
|
|
198
|
+
|
|
199
|
+
If ``expected_modified_gmt`` is given, re-check the server's current value
|
|
200
|
+
first and raise :class:`ConflictError` if it changed (app-level lost-update
|
|
201
|
+
guard — WordPress posts don't expose strong ETags).
|
|
202
|
+
"""
|
|
203
|
+
post_type = settings.post_type if settings is not None else "post"
|
|
204
|
+
if expected_modified_gmt is not None:
|
|
205
|
+
current = await self.get_post(post_id, post_type)
|
|
206
|
+
if current.modified_gmt != expected_modified_gmt:
|
|
207
|
+
raise ConflictError(
|
|
208
|
+
"The post was modified on the server since you opened it.",
|
|
209
|
+
server_modified_gmt=current.modified_gmt,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
payload: dict[str, Any] = {}
|
|
213
|
+
if content_raw is not None:
|
|
214
|
+
payload["content"] = content_raw
|
|
215
|
+
if title_raw is not None:
|
|
216
|
+
payload["title"] = title_raw
|
|
217
|
+
if settings is not None:
|
|
218
|
+
payload.update(settings.to_payload())
|
|
219
|
+
|
|
220
|
+
params = {"context": "edit", "_fields": _POST_FIELDS}
|
|
221
|
+
path = _type_path(post_type)
|
|
222
|
+
response = await self._request(
|
|
223
|
+
"POST", f"/{path}/{post_id}", params=params, json=payload
|
|
224
|
+
)
|
|
225
|
+
return _post_detail(self._json(response))
|
|
226
|
+
|
|
227
|
+
# -- media --------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
async def upload_media(
|
|
230
|
+
self,
|
|
231
|
+
file_path: str,
|
|
232
|
+
*,
|
|
233
|
+
title: str = "",
|
|
234
|
+
alt: str = "",
|
|
235
|
+
caption: str = "",
|
|
236
|
+
description: str = "",
|
|
237
|
+
timeout: float = 120.0,
|
|
238
|
+
) -> MediaItem:
|
|
239
|
+
"""Upload a local file to the media library in one multipart request.
|
|
240
|
+
|
|
241
|
+
Uses an extended timeout since media POSTs can be large. Raises
|
|
242
|
+
:class:`NetworkError` if the file can't be read or the server rejects it.
|
|
243
|
+
"""
|
|
244
|
+
path = Path(file_path)
|
|
245
|
+
try:
|
|
246
|
+
data = path.read_bytes()
|
|
247
|
+
except OSError as err:
|
|
248
|
+
raise NetworkError(f"Cannot read file '{file_path}': {err}") from err
|
|
249
|
+
files = {"file": (path.name, data, _guess_mime(path.name))}
|
|
250
|
+
form: dict[str, str] = {}
|
|
251
|
+
if title:
|
|
252
|
+
form["title"] = title
|
|
253
|
+
if alt:
|
|
254
|
+
form["alt_text"] = alt
|
|
255
|
+
if caption:
|
|
256
|
+
form["caption"] = caption
|
|
257
|
+
if description:
|
|
258
|
+
form["description"] = description
|
|
259
|
+
response = await self._request(
|
|
260
|
+
"POST", "/media", files=files, data=form, timeout=timeout
|
|
261
|
+
)
|
|
262
|
+
return _media_item(self._json(response))
|
|
263
|
+
|
|
264
|
+
async def get_media(self, media_id: int) -> MediaItem:
|
|
265
|
+
"""Fetch one media item (used to resolve a featured image for display)."""
|
|
266
|
+
params = {"context": "edit", "_fields": "id,source_url,alt_text,caption,title,mime_type"}
|
|
267
|
+
response = await self._request("GET", f"/media/{media_id}", params=params)
|
|
268
|
+
return _media_item(self._json(response))
|
|
269
|
+
|
|
270
|
+
async def list_media(
|
|
271
|
+
self, search: str | None = None, *, per_page: int = 30
|
|
272
|
+
) -> list[MediaItem]:
|
|
273
|
+
"""List recent library images (newest first), optionally filtered by ``search``."""
|
|
274
|
+
params: dict[str, Any] = {
|
|
275
|
+
"context": "edit",
|
|
276
|
+
"media_type": "image",
|
|
277
|
+
"orderby": "date",
|
|
278
|
+
"order": "desc",
|
|
279
|
+
"per_page": per_page,
|
|
280
|
+
"_fields": "id,source_url,alt_text,caption,title,mime_type",
|
|
281
|
+
}
|
|
282
|
+
if search:
|
|
283
|
+
params["search"] = search
|
|
284
|
+
response = await self._request("GET", "/media", params=params)
|
|
285
|
+
return _parse_list(self._json(response), MediaItem.from_json, "media items")
|
|
286
|
+
|
|
287
|
+
# -- taxonomy terms -----------------------------------------------------
|
|
288
|
+
|
|
289
|
+
async def list_terms(
|
|
290
|
+
self, taxonomy: str, search: str | None = None, *, per_page: int = 50
|
|
291
|
+
) -> list[Term]:
|
|
292
|
+
"""List terms for a taxonomy REST route (``categories`` or ``tags``)."""
|
|
293
|
+
params: dict[str, Any] = {
|
|
294
|
+
"context": "edit",
|
|
295
|
+
"per_page": per_page,
|
|
296
|
+
"_fields": "id,name,taxonomy",
|
|
297
|
+
"orderby": "count",
|
|
298
|
+
"order": "desc",
|
|
299
|
+
}
|
|
300
|
+
if search:
|
|
301
|
+
params["search"] = search
|
|
302
|
+
response = await self._request("GET", f"/{taxonomy}", params=params)
|
|
303
|
+
return _parse_list(self._json(response), Term.from_json, "terms")
|
|
304
|
+
|
|
305
|
+
async def create_term(self, taxonomy: str, name: str) -> Term:
|
|
306
|
+
"""Create a term, or return the existing one when the name already exists.
|
|
307
|
+
|
|
308
|
+
The name is lowercased first so casing-only variants ("Apple" vs "apple") collapse to
|
|
309
|
+
a single term. WordPress enforces term uniqueness by slug, so a duplicate name comes
|
|
310
|
+
back as HTTP 400 ``term_exists`` carrying the existing ``term_id``; we resolve and
|
|
311
|
+
return that term instead of raising, so an inline "add" of a name that already exists
|
|
312
|
+
lands on the term the user meant rather than dead-ending on an error.
|
|
313
|
+
"""
|
|
314
|
+
name = name.strip().lower()
|
|
315
|
+
params = {"context": "edit", "_fields": "id,name,taxonomy"}
|
|
316
|
+
response = await self._send(
|
|
317
|
+
"POST", f"/{taxonomy}", params=params, json={"name": name}
|
|
318
|
+
)
|
|
319
|
+
if response.status_code == 400:
|
|
320
|
+
term_id = _term_exists_id(response)
|
|
321
|
+
if term_id is not None:
|
|
322
|
+
return await self._get_term(taxonomy, term_id)
|
|
323
|
+
_raise_for_status(response)
|
|
324
|
+
data = self._json(response)
|
|
325
|
+
if not isinstance(data, dict) or "id" not in data:
|
|
326
|
+
raise NetworkError("Unexpected response creating a term.")
|
|
327
|
+
return Term.from_json(data)
|
|
328
|
+
|
|
329
|
+
async def _get_term(self, taxonomy: str, term_id: int) -> Term:
|
|
330
|
+
"""Fetch one term by id (resolves a ``term_exists`` collision to a real Term)."""
|
|
331
|
+
params = {"context": "edit", "_fields": "id,name,taxonomy"}
|
|
332
|
+
response = await self._request("GET", f"/{taxonomy}/{term_id}", params=params)
|
|
333
|
+
data = self._json(response)
|
|
334
|
+
if not isinstance(data, dict) or "id" not in data:
|
|
335
|
+
raise NetworkError("Unexpected response fetching a term.")
|
|
336
|
+
return Term.from_json(data)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _parse_list(
|
|
340
|
+
data: Any, from_json: Callable[[dict[str, Any]], Any], label: str
|
|
341
|
+
) -> list[Any]:
|
|
342
|
+
"""Coerce a JSON list body into DTOs, rejecting a non-list shape as a NetworkError.
|
|
343
|
+
|
|
344
|
+
Shared by ``list_posts``/``list_media``/``list_terms`` so the list-shape guard and the
|
|
345
|
+
skip-non-dict-entries rule stay identical across all three collection reads.
|
|
346
|
+
"""
|
|
347
|
+
if not isinstance(data, list):
|
|
348
|
+
raise NetworkError(f"Expected a list of {label} from the server.")
|
|
349
|
+
return [from_json(item) for item in data if isinstance(item, dict)]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _post_detail(data: Any) -> PostDetail:
|
|
353
|
+
"""Build a PostDetail, rejecting an unexpected body shape as a NetworkError."""
|
|
354
|
+
if not isinstance(data, dict) or "id" not in data:
|
|
355
|
+
raise NetworkError("Unexpected response shape for a post.")
|
|
356
|
+
return PostDetail.from_json(data)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _media_item(data: Any) -> MediaItem:
|
|
360
|
+
"""Build a MediaItem, rejecting an unexpected body shape as a NetworkError."""
|
|
361
|
+
if not isinstance(data, dict) or "id" not in data:
|
|
362
|
+
raise NetworkError("Unexpected response shape for a media item.")
|
|
363
|
+
return MediaItem.from_json(data)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _term_exists_id(response: httpx.Response) -> int | None:
|
|
367
|
+
"""Return the existing term id from a WordPress ``term_exists`` 400 body, else None.
|
|
368
|
+
|
|
369
|
+
A duplicate-name POST returns ``{"code": "term_exists", "data": {"term_id": N}}``. WP has
|
|
370
|
+
historically typed ``term_id`` as either an int or a numeric string, so accept both; a
|
|
371
|
+
bool (an int subclass) is rejected so a stray ``true`` can't masquerade as id 1.
|
|
372
|
+
"""
|
|
373
|
+
try:
|
|
374
|
+
body = response.json()
|
|
375
|
+
except ValueError:
|
|
376
|
+
return None
|
|
377
|
+
if not isinstance(body, dict) or body.get("code") != "term_exists":
|
|
378
|
+
return None
|
|
379
|
+
data = body.get("data")
|
|
380
|
+
if not isinstance(data, dict):
|
|
381
|
+
return None
|
|
382
|
+
term_id = data.get("term_id")
|
|
383
|
+
if isinstance(term_id, bool):
|
|
384
|
+
return None
|
|
385
|
+
if isinstance(term_id, int):
|
|
386
|
+
return term_id
|
|
387
|
+
if isinstance(term_id, str) and term_id.isdigit():
|
|
388
|
+
return int(term_id)
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
393
|
+
if response.is_success:
|
|
394
|
+
return
|
|
395
|
+
code = response.status_code
|
|
396
|
+
detail = _error_detail(response)
|
|
397
|
+
if code in (401, 403):
|
|
398
|
+
raise AuthError(detail or f"Authentication failed (HTTP {code}).")
|
|
399
|
+
if code == 404:
|
|
400
|
+
raise NotFoundError(detail or "Not found (HTTP 404).")
|
|
401
|
+
raise NetworkError(detail or f"Request failed (HTTP {code}).")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _error_detail(response: httpx.Response) -> str:
|
|
405
|
+
try:
|
|
406
|
+
body = response.json()
|
|
407
|
+
except ValueError:
|
|
408
|
+
return ""
|
|
409
|
+
if isinstance(body, dict):
|
|
410
|
+
return str(body.get("message", "")) or ""
|
|
411
|
+
return ""
|
wptui/api/dto.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Data-transfer objects mirroring the raw REST API shapes we consume."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class PostSummary:
|
|
11
|
+
"""A row in the post list."""
|
|
12
|
+
|
|
13
|
+
id: int
|
|
14
|
+
title: str
|
|
15
|
+
status: str
|
|
16
|
+
modified_gmt: str
|
|
17
|
+
link: str
|
|
18
|
+
post_type: str = "post"
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_json(cls, data: dict[str, Any]) -> "PostSummary":
|
|
22
|
+
return cls(
|
|
23
|
+
id=data["id"],
|
|
24
|
+
title=_rendered(data.get("title")) or "(no title)",
|
|
25
|
+
status=data.get("status", ""),
|
|
26
|
+
modified_gmt=data.get("modified_gmt", ""),
|
|
27
|
+
link=data.get("link", ""),
|
|
28
|
+
post_type=data.get("type", "post"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class PostDetail:
|
|
34
|
+
"""A single post opened for editing, carrying raw content and editable settings."""
|
|
35
|
+
|
|
36
|
+
id: int
|
|
37
|
+
title_raw: str
|
|
38
|
+
content_raw: str
|
|
39
|
+
status: str
|
|
40
|
+
modified_gmt: str
|
|
41
|
+
link: str
|
|
42
|
+
post_type: str = "post"
|
|
43
|
+
slug: str = ""
|
|
44
|
+
excerpt_raw: str = ""
|
|
45
|
+
date: str = ""
|
|
46
|
+
password: str = ""
|
|
47
|
+
categories: tuple[int, ...] = ()
|
|
48
|
+
tags: tuple[int, ...] = ()
|
|
49
|
+
featured_media: int = 0
|
|
50
|
+
parent: int = 0
|
|
51
|
+
menu_order: int = 0
|
|
52
|
+
template: str = ""
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_json(cls, data: dict[str, Any]) -> "PostDetail":
|
|
56
|
+
return cls(
|
|
57
|
+
id=data["id"],
|
|
58
|
+
title_raw=_raw(data.get("title")),
|
|
59
|
+
content_raw=_raw(data.get("content")),
|
|
60
|
+
status=data.get("status", ""),
|
|
61
|
+
modified_gmt=data.get("modified_gmt", ""),
|
|
62
|
+
link=data.get("link", ""),
|
|
63
|
+
post_type=data.get("type", "post"),
|
|
64
|
+
slug=data.get("slug", ""),
|
|
65
|
+
excerpt_raw=_raw(data.get("excerpt")),
|
|
66
|
+
date=data.get("date", ""),
|
|
67
|
+
password=data.get("password", ""),
|
|
68
|
+
categories=tuple(data.get("categories", []) or []),
|
|
69
|
+
tags=tuple(data.get("tags", []) or []),
|
|
70
|
+
featured_media=data.get("featured_media", 0) or 0,
|
|
71
|
+
parent=data.get("parent", 0) or 0,
|
|
72
|
+
menu_order=data.get("menu_order", 0) or 0,
|
|
73
|
+
template=data.get("template", ""),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class PostSettings:
|
|
79
|
+
"""The editor's in-memory, editable settings for one post/page.
|
|
80
|
+
|
|
81
|
+
Seeded from a :class:`PostDetail` on load (so untouched fields round-trip), mutated by
|
|
82
|
+
the settings screen, and serialized to a REST payload merged into the save request.
|
|
83
|
+
``to_payload`` emits only the keys valid for its ``post_type`` (categories/tags for
|
|
84
|
+
posts; parent/menu_order/template for pages).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
post_type: str = "post"
|
|
88
|
+
status: str = "draft"
|
|
89
|
+
slug: str = ""
|
|
90
|
+
excerpt_raw: str = ""
|
|
91
|
+
date: str = ""
|
|
92
|
+
password: str = ""
|
|
93
|
+
categories: list[int] = field(default_factory=list)
|
|
94
|
+
tags: list[int] = field(default_factory=list)
|
|
95
|
+
featured_media: int = 0
|
|
96
|
+
parent: int = 0
|
|
97
|
+
menu_order: int = 0
|
|
98
|
+
template: str = ""
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_detail(cls, detail: PostDetail) -> "PostSettings":
|
|
102
|
+
return cls(
|
|
103
|
+
post_type=detail.post_type or "post",
|
|
104
|
+
status=detail.status or "draft",
|
|
105
|
+
slug=detail.slug,
|
|
106
|
+
excerpt_raw=detail.excerpt_raw,
|
|
107
|
+
date=detail.date,
|
|
108
|
+
password=detail.password,
|
|
109
|
+
categories=list(detail.categories),
|
|
110
|
+
tags=list(detail.tags),
|
|
111
|
+
featured_media=detail.featured_media,
|
|
112
|
+
parent=detail.parent,
|
|
113
|
+
menu_order=detail.menu_order,
|
|
114
|
+
template=detail.template,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def to_payload(self) -> dict[str, Any]:
|
|
118
|
+
# Every field here is seeded from the loaded post, so sending it back is
|
|
119
|
+
# idempotent — and sending it even when empty is what lets the user *clear* it
|
|
120
|
+
# (notably removing a password / excerpt / slug). ``date`` is the exception: it's
|
|
121
|
+
# omitted when empty so a new post defaults to "now" rather than being rejected.
|
|
122
|
+
payload: dict[str, Any] = {
|
|
123
|
+
"status": self.status,
|
|
124
|
+
"slug": self.slug,
|
|
125
|
+
"excerpt": self.excerpt_raw,
|
|
126
|
+
"password": self.password,
|
|
127
|
+
"featured_media": self.featured_media,
|
|
128
|
+
}
|
|
129
|
+
if self.date:
|
|
130
|
+
payload["date"] = self.date
|
|
131
|
+
if self.post_type == "page":
|
|
132
|
+
payload["parent"] = self.parent
|
|
133
|
+
payload["menu_order"] = self.menu_order
|
|
134
|
+
payload["template"] = self.template
|
|
135
|
+
else:
|
|
136
|
+
payload["categories"] = list(self.categories)
|
|
137
|
+
payload["tags"] = list(self.tags)
|
|
138
|
+
return payload
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class Term:
|
|
143
|
+
"""A taxonomy term (category or tag)."""
|
|
144
|
+
|
|
145
|
+
id: int
|
|
146
|
+
name: str
|
|
147
|
+
taxonomy: str
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def from_json(cls, data: dict[str, Any]) -> "Term":
|
|
151
|
+
return cls(
|
|
152
|
+
id=data["id"],
|
|
153
|
+
name=data.get("name", ""),
|
|
154
|
+
taxonomy=data.get("taxonomy", ""),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class MediaItem:
|
|
160
|
+
"""An uploaded media library item."""
|
|
161
|
+
|
|
162
|
+
id: int
|
|
163
|
+
source_url: str
|
|
164
|
+
alt: str = ""
|
|
165
|
+
caption_raw: str = ""
|
|
166
|
+
title_raw: str = ""
|
|
167
|
+
mime: str = ""
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_json(cls, data: dict[str, Any]) -> "MediaItem":
|
|
171
|
+
return cls(
|
|
172
|
+
id=data["id"],
|
|
173
|
+
source_url=data.get("source_url", ""),
|
|
174
|
+
alt=data.get("alt_text", ""),
|
|
175
|
+
caption_raw=_raw(data.get("caption")),
|
|
176
|
+
title_raw=_raw(data.get("title")),
|
|
177
|
+
mime=data.get("mime_type", ""),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _rendered(field_value: dict[str, Any] | None) -> str:
|
|
182
|
+
if not field_value:
|
|
183
|
+
return ""
|
|
184
|
+
return field_value.get("rendered", "") or ""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _raw(field_value: dict[str, Any] | None) -> str:
|
|
188
|
+
"""Prefer ``raw`` (context=edit); fall back to ``rendered`` if absent."""
|
|
189
|
+
if not field_value:
|
|
190
|
+
return ""
|
|
191
|
+
return field_value.get("raw", field_value.get("rendered", "")) or ""
|
wptui/api/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Typed exceptions surfaced from the API layer to the UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApiError(Exception):
|
|
7
|
+
"""Base class for all API-layer errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NetworkError(ApiError):
|
|
11
|
+
"""Connection failed, timed out, or TLS/DNS error — the request never landed."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthError(ApiError):
|
|
15
|
+
"""Authentication or authorization failed (HTTP 401 / 403)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NotFoundError(ApiError):
|
|
19
|
+
"""The requested resource does not exist (HTTP 404)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConflictError(ApiError):
|
|
23
|
+
"""The post changed on the server since we loaded it (lost-update guard)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, *, server_modified_gmt: str | None = None) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.server_modified_gmt = server_modified_gmt
|