wagapi 0.2.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {wagapi-0.2.2 → wagapi-0.3.0}/PKG-INFO +9 -7
  2. {wagapi-0.2.2 → wagapi-0.3.0}/README.md +8 -6
  3. {wagapi-0.2.2 → wagapi-0.3.0}/pyproject.toml +1 -1
  4. {wagapi-0.2.2 → wagapi-0.3.0}/tests/conftest.py +17 -0
  5. {wagapi-0.2.2 → wagapi-0.3.0}/tests/test_commands.py +66 -2
  6. wagapi-0.3.0/tests/test_formatting.py +173 -0
  7. wagapi-0.3.0/wagapi/__init__.py +1 -0
  8. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/cli.py +6 -1
  9. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/commands/pages.py +22 -3
  10. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/formatting/output.py +54 -5
  11. wagapi-0.2.2/wagapi/__init__.py +0 -1
  12. {wagapi-0.2.2 → wagapi-0.3.0}/.github/workflows/publish.yml +0 -0
  13. {wagapi-0.2.2 → wagapi-0.3.0}/.gitignore +0 -0
  14. {wagapi-0.2.2 → wagapi-0.3.0}/tests/__init__.py +0 -0
  15. {wagapi-0.2.2 → wagapi-0.3.0}/tests/test_client.py +0 -0
  16. {wagapi-0.2.2 → wagapi-0.3.0}/tests/test_config.py +0 -0
  17. {wagapi-0.2.2 → wagapi-0.3.0}/tests/test_markdown.py +0 -0
  18. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/__main__.py +0 -0
  19. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/client.py +0 -0
  20. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/commands/__init__.py +0 -0
  21. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/commands/images.py +0 -0
  22. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/commands/init.py +0 -0
  23. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/commands/schema.py +0 -0
  24. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/config.py +0 -0
  25. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/exceptions.py +0 -0
  26. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/formatting/__init__.py +0 -0
  27. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/formatting/markdown.py +0 -0
  28. {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wagapi
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: CLI client for the Wagtail Write API, optimised for LLM orchestration
5
5
  Project-URL: Repository, https://github.com/tomdyson/wagapi
6
6
  Project-URL: Issues, https://github.com/tomdyson/wagapi/issues
@@ -64,10 +64,10 @@ uv tool install wagapi
64
64
 
65
65
  ### 1. Set up a Wagtail site with wagtail-write-api
66
66
 
67
- Follow the [example app guide](https://tomdyson.github.io/wagtail-write-api/development/example-app/) to get a local Wagtail instance running:
67
+ Follow the [example app guide](https://tomdyson.github.io/wagtail-write-api/development/example-app/) to get a local Wagtail instance running with [wagtail-write-api](https://github.com/tomdyson/wagtail-write-api):
68
68
 
69
69
  ```bash
70
- cd example
70
+ cd wagtail-write-api/example
71
71
  uv run python manage.py migrate
72
72
  uv run python manage.py seed_demo
73
73
  uv run python manage.py runserver
@@ -168,7 +168,7 @@ wagapi pages delete <ID>
168
168
  wagapi -v pages get <ID>
169
169
 
170
170
  # Preview without executing
171
- wagapi --dry-run pages create testapp.SimplePage --parent /home/ --title "Test"
171
+ wagapi --dry-run pages create testapp.SimplePage --parent / --title "Test"
172
172
  ```
173
173
 
174
174
  ### 8. Pipe-friendly JSON output
@@ -255,8 +255,8 @@ wagapi pages list [OPTIONS]
255
255
  | Option | Description |
256
256
  |---|---|
257
257
  | `--type TYPE` | Filter by page type, e.g. `testapp.BlogPage` |
258
- | `--parent ID` | Direct children of page ID |
259
- | `--descendant-of ID` | All descendants of page ID |
258
+ | `--parent ID_OR_PATH` | Direct children of page ID or URL path (e.g. `5` or `/blog/`) |
259
+ | `--descendant-of ID_OR_PATH` | All descendants of page ID or URL path |
260
260
  | `--status STATUS` | `draft`, `live`, or `live+draft` |
261
261
  | `--slug SLUG` | Exact slug match |
262
262
  | `--path PATH` | Exact URL path match, e.g. `/blog/my-post/` |
@@ -288,7 +288,9 @@ wagapi pages create <type> --parent ID_OR_PATH --title TITLE [OPTIONS]
288
288
  | `--publish` | Publish immediately (default: create as draft) |
289
289
  | `--raw` | Treat field values as raw JSON (no auto-wrapping) |
290
290
 
291
- **Markdown body (auto-converted to StreamField):**
291
+ **Markdown body (auto-detected: StreamField blocks or RichTextField HTML):**
292
+
293
+ `--body` checks the page type schema to determine the field type. For StreamField fields, markdown is converted to blocks. For RichTextField fields, markdown is sent as-is for server-side conversion to HTML.
292
294
 
293
295
  ```bash
294
296
  wagapi pages create testapp.BlogPage --parent /blog/ \
@@ -34,10 +34,10 @@ uv tool install wagapi
34
34
 
35
35
  ### 1. Set up a Wagtail site with wagtail-write-api
36
36
 
37
- Follow the [example app guide](https://tomdyson.github.io/wagtail-write-api/development/example-app/) to get a local Wagtail instance running:
37
+ Follow the [example app guide](https://tomdyson.github.io/wagtail-write-api/development/example-app/) to get a local Wagtail instance running with [wagtail-write-api](https://github.com/tomdyson/wagtail-write-api):
38
38
 
39
39
  ```bash
40
- cd example
40
+ cd wagtail-write-api/example
41
41
  uv run python manage.py migrate
42
42
  uv run python manage.py seed_demo
43
43
  uv run python manage.py runserver
@@ -138,7 +138,7 @@ wagapi pages delete <ID>
138
138
  wagapi -v pages get <ID>
139
139
 
140
140
  # Preview without executing
141
- wagapi --dry-run pages create testapp.SimplePage --parent /home/ --title "Test"
141
+ wagapi --dry-run pages create testapp.SimplePage --parent / --title "Test"
142
142
  ```
143
143
 
144
144
  ### 8. Pipe-friendly JSON output
@@ -225,8 +225,8 @@ wagapi pages list [OPTIONS]
225
225
  | Option | Description |
226
226
  |---|---|
227
227
  | `--type TYPE` | Filter by page type, e.g. `testapp.BlogPage` |
228
- | `--parent ID` | Direct children of page ID |
229
- | `--descendant-of ID` | All descendants of page ID |
228
+ | `--parent ID_OR_PATH` | Direct children of page ID or URL path (e.g. `5` or `/blog/`) |
229
+ | `--descendant-of ID_OR_PATH` | All descendants of page ID or URL path |
230
230
  | `--status STATUS` | `draft`, `live`, or `live+draft` |
231
231
  | `--slug SLUG` | Exact slug match |
232
232
  | `--path PATH` | Exact URL path match, e.g. `/blog/my-post/` |
@@ -258,7 +258,9 @@ wagapi pages create <type> --parent ID_OR_PATH --title TITLE [OPTIONS]
258
258
  | `--publish` | Publish immediately (default: create as draft) |
259
259
  | `--raw` | Treat field values as raw JSON (no auto-wrapping) |
260
260
 
261
- **Markdown body (auto-converted to StreamField):**
261
+ **Markdown body (auto-detected: StreamField blocks or RichTextField HTML):**
262
+
263
+ `--body` checks the page type schema to determine the field type. For StreamField fields, markdown is converted to blocks. For RichTextField fields, markdown is sent as-is for server-side conversion to HTML.
262
264
 
263
265
  ```bash
264
266
  wagapi pages create testapp.BlogPage --parent /blog/ \
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "wagapi"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "CLI client for the Wagtail Write API, optimised for LLM orchestration"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -51,6 +51,23 @@ SAMPLE_PAGE_TYPE_SCHEMA = {
51
51
  },
52
52
  }
53
53
 
54
+ SAMPLE_RICHTEXT_PAGE_TYPE_SCHEMA = {
55
+ "name": "home.SimplePage",
56
+ "verbose_name": "simple page",
57
+ "create_schema": {
58
+ "required": ["type", "parent", "title"],
59
+ "properties": {
60
+ "type": {"type": "string"},
61
+ "parent": {"type": "integer"},
62
+ "title": {"type": "string"},
63
+ "body": {"type": "string", "description": "RichTextField body content"},
64
+ },
65
+ },
66
+ "allowed_parents": ["wagtailcore.Page"],
67
+ "allowed_children": [],
68
+ "streamfield_blocks": {},
69
+ }
70
+
54
71
  SAMPLE_PAGE = {
55
72
  "id": 42,
56
73
  "title": "Hello",
@@ -158,7 +158,12 @@ def test_pages_create(runner):
158
158
 
159
159
 
160
160
  @respx.mock
161
- def test_pages_create_with_body(runner):
161
+ def test_pages_create_with_body_streamfield(runner):
162
+ """Body is converted to StreamField blocks when the field is a StreamField."""
163
+ schema = {"streamfield_blocks": {"body": [{"type": "heading"}, {"type": "paragraph"}]}}
164
+ respx.get(f"{BASE_URL}/schema/blog.BlogPage/").mock(
165
+ return_value=Response(200, json=schema)
166
+ )
162
167
  data = {
163
168
  "id": 43,
164
169
  "title": "Iris",
@@ -179,13 +184,45 @@ def test_pages_create_with_body(runner):
179
184
  ],
180
185
  )
181
186
  assert result.exit_code == 0
182
- # Verify the request body contained streamfield blocks
183
187
  request_body = json.loads(route.calls[0].request.content)
184
188
  assert isinstance(request_body["body"], list)
185
189
  assert request_body["body"][0]["type"] == "heading"
186
190
  assert request_body["body"][1]["type"] == "paragraph"
187
191
 
188
192
 
193
+ @respx.mock
194
+ def test_pages_create_with_body_richtext(runner):
195
+ """Body is sent as richtext format dict when the field is a RichTextField."""
196
+ schema = {"streamfield_blocks": {}}
197
+ respx.get(f"{BASE_URL}/schema/home.SimplePage/").mock(
198
+ return_value=Response(200, json=schema)
199
+ )
200
+ data = {
201
+ "id": 50,
202
+ "title": "About",
203
+ "slug": "about",
204
+ "meta": {"type": "home.SimplePage", "live": False, "parent_id": 3},
205
+ }
206
+ route = respx.post(f"{BASE_URL}/pages/").mock(
207
+ return_value=Response(201, json=data)
208
+ )
209
+ with mock.patch.dict("os.environ", ENV):
210
+ result = runner.invoke(
211
+ cli,
212
+ [
213
+ "pages", "create", "home.SimplePage",
214
+ "--parent", "3",
215
+ "--title", "About",
216
+ "--body", "Hello **world**",
217
+ ],
218
+ )
219
+ assert result.exit_code == 0
220
+ request_body = json.loads(route.calls[0].request.content)
221
+ assert isinstance(request_body["body"], dict)
222
+ assert request_body["body"]["format"] == "markdown"
223
+ assert request_body["body"]["content"] == "Hello **world**"
224
+
225
+
189
226
  @respx.mock
190
227
  def test_pages_create_with_path_parent(runner):
191
228
  data = {
@@ -245,6 +282,33 @@ def test_pages_update(runner):
245
282
  assert "Updated" in result.output
246
283
 
247
284
 
285
+ @respx.mock
286
+ def test_pages_update_with_body_richtext(runner):
287
+ """Update fetches the page type and sends richtext for RichTextField."""
288
+ # Mock GET to fetch the page (to learn its type)
289
+ page_data = {
290
+ "id": 42, "title": "Old", "body": "<p>old</p>",
291
+ "meta": {"type": "home.SimplePage", "live": True},
292
+ }
293
+ respx.get(f"{BASE_URL}/pages/42/").mock(return_value=Response(200, json=page_data))
294
+ # Mock schema lookup
295
+ schema = {"streamfield_blocks": {}}
296
+ respx.get(f"{BASE_URL}/schema/home.SimplePage/").mock(
297
+ return_value=Response(200, json=schema)
298
+ )
299
+ # Mock PATCH
300
+ updated = {**page_data, "body": "<p>new</p>"}
301
+ route = respx.patch(f"{BASE_URL}/pages/42/").mock(
302
+ return_value=Response(200, json=updated)
303
+ )
304
+ with mock.patch.dict("os.environ", ENV):
305
+ result = runner.invoke(cli, ["pages", "update", "42", "--body", "new content"])
306
+ assert result.exit_code == 0
307
+ request_body = json.loads(route.calls[0].request.content)
308
+ assert isinstance(request_body["body"], dict)
309
+ assert request_body["body"]["format"] == "markdown"
310
+
311
+
248
312
  @respx.mock
249
313
  def test_pages_delete(runner):
250
314
  respx.delete(f"{BASE_URL}/pages/42/").mock(return_value=Response(204))
@@ -0,0 +1,173 @@
1
+ """Tests for wagapi.formatting.output — human-readable formatters."""
2
+
3
+ from wagapi.formatting.output import (
4
+ _richtext_preview,
5
+ _streamfield_preview,
6
+ format_page_detail,
7
+ format_page_list,
8
+ format_schema_detail,
9
+ )
10
+
11
+
12
+ class TestStreamfieldPreview:
13
+ def test_heading_and_paragraph(self):
14
+ blocks = [
15
+ {"type": "heading", "value": {"text": "Hello", "size": "h2"}},
16
+ {"type": "paragraph", "value": "<p>World <b>bold</b></p>"},
17
+ ]
18
+ assert _streamfield_preview(blocks) == "Hello / World bold"
19
+
20
+ def test_plain_text_heading(self):
21
+ blocks = [{"type": "heading", "value": "Just text"}]
22
+ assert _streamfield_preview(blocks) == "Just text"
23
+
24
+ def test_truncation(self):
25
+ blocks = [{"type": "paragraph", "value": "<p>" + "x" * 300 + "</p>"}]
26
+ result = _streamfield_preview(blocks, max_len=50)
27
+ assert len(result) == 50
28
+ assert result.endswith("…")
29
+
30
+ def test_empty_blocks(self):
31
+ assert _streamfield_preview([]) == ""
32
+
33
+ def test_unknown_block_types_skipped(self):
34
+ blocks = [{"type": "image", "value": 42}]
35
+ assert _streamfield_preview(blocks) == ""
36
+
37
+
38
+ class TestRichtextPreview:
39
+ def test_strips_html(self):
40
+ assert _richtext_preview("<p>Hello <b>world</b></p>") == "Hello world"
41
+
42
+ def test_truncation(self):
43
+ result = _richtext_preview("<p>" + "x" * 300 + "</p>", max_len=50)
44
+ assert len(result) == 50
45
+ assert result.endswith("…")
46
+
47
+ def test_empty(self):
48
+ assert _richtext_preview("") == ""
49
+
50
+
51
+ class TestFormatPageList:
52
+ def test_includes_url_path(self):
53
+ data = {
54
+ "meta": {"total_count": 1},
55
+ "items": [
56
+ {
57
+ "id": 6,
58
+ "title": "Blog Post",
59
+ "meta": {
60
+ "type": "blog.BlogPage",
61
+ "live": True,
62
+ "url_path": "/blog/blog-post/",
63
+ },
64
+ }
65
+ ],
66
+ }
67
+ result = format_page_list(data)
68
+ assert "/blog/blog-post/" in result
69
+
70
+ def test_no_url_path_still_works(self):
71
+ data = {
72
+ "meta": {"total_count": 1},
73
+ "items": [
74
+ {
75
+ "id": 1,
76
+ "title": "Root",
77
+ "meta": {"type": "wagtailcore.Page", "live": True},
78
+ }
79
+ ],
80
+ }
81
+ result = format_page_list(data)
82
+ assert "Root" in result
83
+
84
+
85
+ class TestFormatPageDetail:
86
+ def test_shows_url_path(self):
87
+ data = {
88
+ "id": 6,
89
+ "title": "Blog Post",
90
+ "slug": "blog-post",
91
+ "meta": {
92
+ "type": "blog.BlogPage",
93
+ "live": True,
94
+ "url_path": "/blog/blog-post/",
95
+ "parent_id": 5,
96
+ },
97
+ }
98
+ result = format_page_detail(data)
99
+ assert "Path: /blog/blog-post/" in result
100
+
101
+ def test_streamfield_body_preview(self):
102
+ data = {
103
+ "id": 6,
104
+ "title": "Post",
105
+ "slug": "post",
106
+ "meta": {"type": "blog.BlogPage", "live": True},
107
+ "body": [
108
+ {"type": "heading", "value": {"text": "Intro", "size": "h2"}},
109
+ {"type": "paragraph", "value": "<p>Some content here.</p>"},
110
+ ],
111
+ }
112
+ result = format_page_detail(data)
113
+ assert "body: Intro / Some content here." in result
114
+
115
+ def test_richtext_body_preview(self):
116
+ data = {
117
+ "id": 4,
118
+ "title": "About",
119
+ "slug": "about",
120
+ "meta": {"type": "home.SimplePage", "live": True},
121
+ "body": "<p>About us page content</p>",
122
+ }
123
+ result = format_page_detail(data)
124
+ assert "body: About us page content" in result
125
+
126
+
127
+ class TestFormatSchemaDetail:
128
+ def test_streamfield_type_annotation(self):
129
+ data = {
130
+ "type": "blog.BlogPage",
131
+ "create_schema": {
132
+ "required": ["body"],
133
+ "properties": {
134
+ "body": {"type": "array", "title": "Body"},
135
+ },
136
+ },
137
+ "streamfield_blocks": {"body": [{"type": "paragraph"}]},
138
+ "richtext_fields": [],
139
+ }
140
+ result = format_schema_detail(data)
141
+ assert "StreamField" in result
142
+
143
+ def test_richtext_type_annotation(self):
144
+ data = {
145
+ "type": "home.SimplePage",
146
+ "create_schema": {
147
+ "required": [],
148
+ "properties": {
149
+ "body": {"type": "string", "title": "Body"},
150
+ },
151
+ },
152
+ "streamfield_blocks": {},
153
+ "richtext_fields": ["body"],
154
+ }
155
+ result = format_schema_detail(data)
156
+ assert "RichText" in result
157
+
158
+ def test_regular_string_not_annotated(self):
159
+ data = {
160
+ "type": "blog.BlogPage",
161
+ "create_schema": {
162
+ "required": [],
163
+ "properties": {
164
+ "slug": {"type": "string", "title": "Slug"},
165
+ },
166
+ },
167
+ "streamfield_blocks": {},
168
+ "richtext_fields": [],
169
+ }
170
+ result = format_schema_detail(data)
171
+ assert "RichText" not in result
172
+ assert "StreamField" not in result
173
+ assert "string" in result
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -52,7 +52,8 @@ def handle_api_errors(fn):
52
52
  return wrapper
53
53
 
54
54
 
55
- @click.group()
55
+ @click.group(invoke_without_command=True)
56
+ @click.version_option(package_name="wagapi")
56
57
  @click.option("--url", envvar="WAGAPI_URL", default=None, help="API base URL")
57
58
  @click.option("--token", envvar="WAGAPI_TOKEN", default=None, help="API token")
58
59
  @click.option("--json", "output_json", is_flag=True, help="Force JSON output")
@@ -62,6 +63,10 @@ def handle_api_errors(fn):
62
63
  @click.pass_context
63
64
  def cli(ctx: click.Context, url, token, output_json, output_human, verbose, dry_run):
64
65
  """wagapi — CLI client for the Wagtail Write API."""
66
+ if ctx.invoked_subcommand is None:
67
+ click.echo(ctx.get_help())
68
+ return
69
+
65
70
  obj = Context()
66
71
  obj.force_json = output_json
67
72
  obj.force_human = output_human
@@ -7,7 +7,7 @@ import click
7
7
 
8
8
  from wagapi.cli import Context, handle_api_errors, pass_ctx
9
9
  from wagapi.exceptions import UsageError
10
- from wagapi.formatting.markdown import markdown_to_streamfield
10
+ from wagapi.formatting.markdown import markdown_to_richtext, markdown_to_streamfield
11
11
  from wagapi.formatting.output import (
12
12
  format_page_created,
13
13
  format_page_deleted,
@@ -27,6 +27,15 @@ def _require_client(ctx: Context):
27
27
  )
28
28
 
29
29
 
30
+ def _is_streamfield(ctx: Context, page_type: str, field_name: str) -> bool:
31
+ """Check if a field is a StreamField by looking for it in streamfield_blocks."""
32
+ try:
33
+ schema = ctx.client.get_page_type_schema(page_type)
34
+ return field_name in schema.get("streamfield_blocks", {})
35
+ except Exception:
36
+ return True # default to StreamField if schema lookup fails
37
+
38
+
30
39
  def _parse_parent(value: str) -> int | str:
31
40
  """Parse parent as int ID or string path."""
32
41
  try:
@@ -175,7 +184,11 @@ def create(ctx: Context, page_type, parent, title, slug, fields, body, publish,
175
184
  except json.JSONDecodeError:
176
185
  data["body"] = body
177
186
  else:
178
- data["body"] = markdown_to_streamfield(body)
187
+ # Check if body is a StreamField or RichTextField
188
+ if _is_streamfield(ctx, page_type, "body"):
189
+ data["body"] = markdown_to_streamfield(body)
190
+ else:
191
+ data["body"] = markdown_to_richtext(body)
179
192
 
180
193
  if publish:
181
194
  data["action"] = "publish"
@@ -225,7 +238,13 @@ def update(ctx: Context, page_id, title, slug, fields, body, publish, raw):
225
238
  except json.JSONDecodeError:
226
239
  data["body"] = body
227
240
  else:
228
- data["body"] = markdown_to_streamfield(body)
241
+ # Fetch page to determine its type, then check schema
242
+ page_data = ctx.client.get_page(page_id)
243
+ page_type = page_data.get("meta", {}).get("type", "")
244
+ if _is_streamfield(ctx, page_type, "body"):
245
+ data["body"] = markdown_to_streamfield(body)
246
+ else:
247
+ data["body"] = markdown_to_richtext(body)
229
248
 
230
249
  if publish:
231
250
  data["action"] = "publish"
@@ -65,6 +65,36 @@ def format_page_unpublished(data: dict) -> str:
65
65
  return f'✓ Unpublished page {data.get("id", "?")} "{data.get("title", "")}"'
66
66
 
67
67
 
68
+ def _streamfield_preview(blocks: list[dict], max_len: int = 200) -> str:
69
+ """Extract a plain-text preview from StreamField blocks."""
70
+ import re
71
+
72
+ parts = []
73
+ for block in blocks:
74
+ btype = block.get("type", "")
75
+ val = block.get("value", "")
76
+ if btype == "heading":
77
+ text = val.get("text", "") if isinstance(val, dict) else str(val)
78
+ parts.append(text)
79
+ elif btype in ("paragraph", "text"):
80
+ text = re.sub(r"<[^>]+>", "", str(val))
81
+ parts.append(text)
82
+ preview = " / ".join(parts)
83
+ if len(preview) > max_len:
84
+ preview = preview[: max_len - 1] + "…"
85
+ return preview
86
+
87
+
88
+ def _richtext_preview(html: str, max_len: int = 200) -> str:
89
+ """Extract a plain-text preview from HTML rich text."""
90
+ import re
91
+
92
+ text = re.sub(r"<[^>]+>", "", str(html)).strip()
93
+ if len(text) > max_len:
94
+ text = text[: max_len - 1] + "…"
95
+ return text
96
+
97
+
68
98
  def format_page_detail(data: dict) -> str:
69
99
  meta = data.get("meta", {})
70
100
  status = "live" if meta.get("live") else "draft"
@@ -73,7 +103,9 @@ def format_page_detail(data: dict) -> str:
73
103
  f' Type: {meta.get("type", "unknown")}',
74
104
  f' Slug: {data.get("slug", "")}',
75
105
  ]
76
- if meta.get("html_url"):
106
+ if meta.get("url_path"):
107
+ lines.append(f' Path: {meta["url_path"]}')
108
+ elif meta.get("html_url"):
77
109
  lines.append(f' URL: {meta["html_url"]}')
78
110
  if meta.get("parent_id"):
79
111
  lines.append(f' Parent: {meta["parent_id"]}')
@@ -84,13 +116,20 @@ def format_page_detail(data: dict) -> str:
84
116
  if key in skip or value is None:
85
117
  continue
86
118
  if isinstance(value, list) and value and isinstance(value[0], dict) and "type" in value[0]:
87
- # StreamField — summarise block types
88
- block_types = [b.get("type", "?") for b in value]
89
- lines.append(f" {key}: [{', '.join(block_types)}] ({len(value)} blocks)")
119
+ # StreamField — show preview
120
+ preview = _streamfield_preview(value)
121
+ if preview:
122
+ lines.append(f" {key}: {preview}")
123
+ else:
124
+ block_types = [b.get("type", "?") for b in value]
125
+ lines.append(f" {key}: [{', '.join(block_types)}] ({len(value)} blocks)")
90
126
  elif isinstance(value, list) and value:
91
127
  lines.append(f" {key}: {len(value)} items")
92
128
  elif isinstance(value, list):
93
129
  pass # skip empty lists
130
+ elif isinstance(value, str) and value.startswith("<"):
131
+ # RichTextField HTML — show preview
132
+ lines.append(f" {key}: {_richtext_preview(value)}")
94
133
  else:
95
134
  lines.append(f" {key}: {value}")
96
135
 
@@ -105,9 +144,11 @@ def format_page_list(data: dict) -> str:
105
144
  for page in items:
106
145
  meta = page.get("meta", {})
107
146
  status = "live" if meta.get("live") else "draft"
147
+ url_path = meta.get("url_path", "")
148
+ path_col = f" {url_path}" if url_path else ""
108
149
  lines.append(
109
150
  f' {page["id"]:>5} {page.get("title", "Untitled"):<40} '
110
- f'{meta.get("type", ""):<30} {status}'
151
+ f'{meta.get("type", ""):<30} {status}{path_col}'
111
152
  )
112
153
  total = data.get("meta", {}).get("total_count", len(items))
113
154
  header = f"Pages ({total} total):\n"
@@ -147,6 +188,9 @@ def format_schema_detail(data: dict) -> str:
147
188
  properties = create_schema.get("properties", {})
148
189
  required_fields = set(create_schema.get("required", []))
149
190
 
191
+ streamfield_fields = set(data.get("streamfield_blocks", {}).keys())
192
+ richtext_fields = set(data.get("richtext_fields", []))
193
+
150
194
  if properties:
151
195
  req_lines = []
152
196
  opt_lines = []
@@ -157,6 +201,11 @@ def format_schema_detail(data: dict) -> str:
157
201
  types = [t.get("type", t.get("format", "")) for t in field_schema["anyOf"] if t.get("type") != "null"]
158
202
  ftype = types[0] if types else "unknown"
159
203
  ftype = ftype or "unknown"
204
+ # Use Wagtail-specific type names for clarity
205
+ if field_name in streamfield_fields:
206
+ ftype = "StreamField"
207
+ elif field_name in richtext_fields:
208
+ ftype = "RichText"
160
209
  desc = field_schema.get("description", field_schema.get("title", ""))
161
210
  entry = f" {field_name:<20} {ftype:<15} {desc}"
162
211
  if field_name in required_fields:
@@ -1 +0,0 @@
1
- __version__ = "0.2.2"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes