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.
Files changed (48) hide show
  1. wptui/__init__.py +3 -0
  2. wptui/__main__.py +13 -0
  3. wptui/api/__init__.py +25 -0
  4. wptui/api/client.py +411 -0
  5. wptui/api/dto.py +191 -0
  6. wptui/api/errors.py +27 -0
  7. wptui/app.py +61 -0
  8. wptui/app.tcss +276 -0
  9. wptui/autosave.py +83 -0
  10. wptui/blocks/__init__.py +12 -0
  11. wptui/blocks/factory.py +64 -0
  12. wptui/blocks/grammar.py +201 -0
  13. wptui/blocks/image.py +108 -0
  14. wptui/blocks/model.py +79 -0
  15. wptui/blocks/serialize.py +112 -0
  16. wptui/blocks/text.py +69 -0
  17. wptui/config.py +139 -0
  18. wptui/inline/__init__.py +34 -0
  19. wptui/inline/html_parse.py +81 -0
  20. wptui/inline/html_serialize.py +65 -0
  21. wptui/inline/markup.py +358 -0
  22. wptui/inline/model.py +115 -0
  23. wptui/keys/__init__.py +13 -0
  24. wptui/keys/modes.py +26 -0
  25. wptui/keys/vim.py +115 -0
  26. wptui/paths.py +48 -0
  27. wptui/screens/__init__.py +1 -0
  28. wptui/screens/connect.py +133 -0
  29. wptui/screens/editor.py +463 -0
  30. wptui/screens/post_list.py +98 -0
  31. wptui/screens/post_settings.py +176 -0
  32. wptui/widgets/__init__.py +1 -0
  33. wptui/widgets/canvas.py +227 -0
  34. wptui/widgets/confirm.py +53 -0
  35. wptui/widgets/image_card.py +73 -0
  36. wptui/widgets/image_upload.py +76 -0
  37. wptui/widgets/inline_area.py +219 -0
  38. wptui/widgets/media_picker.py +88 -0
  39. wptui/widgets/opaque_card.py +42 -0
  40. wptui/widgets/separator_card.py +22 -0
  41. wptui/widgets/term_picker.py +107 -0
  42. wptui/widgets/text_block.py +49 -0
  43. wptui-0.1.1.dist-info/METADATA +94 -0
  44. wptui-0.1.1.dist-info/RECORD +48 -0
  45. wptui-0.1.1.dist-info/WHEEL +5 -0
  46. wptui-0.1.1.dist-info/entry_points.txt +2 -0
  47. wptui-0.1.1.dist-info/licenses/LICENSE +674 -0
  48. wptui-0.1.1.dist-info/top_level.txt +1 -0
wptui/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """wp-tui: a terminal UI for editing WordPress posts."""
2
+
3
+ __version__ = "0.1.0"
wptui/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Console entry point: ``python -m wptui`` / ``wptui``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from wptui.app import WPTuiApp
6
+
7
+
8
+ def main() -> None:
9
+ WPTuiApp().run()
10
+
11
+
12
+ if __name__ == "__main__":
13
+ main()
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