feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats
Settings fixes: - MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings table (DB > ENV > default), so dashboard/settings changes apply without restart. Sync cache refreshed on every save or delete. - /api/me reports the live DB value for max_sites_per_user. Admin improvements: - Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS). - Admin Overview now shows platform stats: registered users, new users (7d), total user sites, available tools. Plugin cleanup: - Appwrite and Directus plugins removed from the active registry (8 plugins now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Plugin code is retained for future re-enabling. - Settings page plugin visibility list updated to match. Mobile onboarding: - Stepper steps on narrow viewports stack vertically with correct full border and rounded corners on each step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -370,16 +370,86 @@ async def _apply_metadata_and_attach(
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status["warnings"].append(f"featured_set_failed (product): {exc}")
|
||||
else:
|
||||
try:
|
||||
await wp_set_featured_media(client, attach_to_post, media["id"])
|
||||
# Issue #90 — set_featured used to hit /wp/v2/posts/{id}
|
||||
# only, which 404s for pages and other CPTs. Now: try
|
||||
# `posts` first (the common case), and on miss, walk the
|
||||
# thumbnail-supporting rest_bases (pages, then any other
|
||||
# CPT that opted in via add_theme_support('post-thumbnails')
|
||||
# / declares ``thumbnail`` in its supports list).
|
||||
ctx = await _set_featured_with_fallback(client, attach_to_post, media["id"])
|
||||
if ctx is not None:
|
||||
status["featured_set"] = True
|
||||
status["featured_context"] = "post"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status["warnings"].append(f"featured_set_failed (post): {exc}")
|
||||
status["featured_context"] = ctx
|
||||
else:
|
||||
status["warnings"].append(
|
||||
f"featured_set_failed: post {attach_to_post} not found "
|
||||
"in any thumbnail-supporting post type"
|
||||
)
|
||||
|
||||
return status
|
||||
|
||||
|
||||
async def _set_featured_with_fallback(
|
||||
client: WordPressClient,
|
||||
target_id: int,
|
||||
media_id: int,
|
||||
) -> str | None:
|
||||
"""Set featured image on ``target_id`` across post / page / CPT.
|
||||
|
||||
Tries ``posts`` first (the 99% case — and the path covered by
|
||||
``wp_set_featured_media`` to keep existing test seams stable),
|
||||
then falls back to ``pages`` and any other rest_base that declares
|
||||
``thumbnail`` support via ``/wp/v2/types``. Returns the rest_base on
|
||||
success, ``None`` when nothing accepted the write.
|
||||
"""
|
||||
try:
|
||||
await wp_set_featured_media(client, target_id, media_id)
|
||||
return "post"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
body = {"featured_media": media_id}
|
||||
|
||||
try:
|
||||
await client.post(f"pages/{target_id}", json_data=body)
|
||||
return "page"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
types_data = await client.get("types")
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(types_data, dict):
|
||||
return None
|
||||
|
||||
for slug, data in types_data.items():
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
rest_base = data.get("rest_base") or slug
|
||||
if rest_base in ("posts", "pages", "media", "attachment"):
|
||||
continue
|
||||
supports = data.get("supports") or {}
|
||||
# `supports` is sometimes a list (older WP / some plugins) and
|
||||
# sometimes a dict keyed by feature. Treat both as truthy when
|
||||
# ``thumbnail`` is present.
|
||||
if isinstance(supports, dict):
|
||||
if not supports.get("thumbnail"):
|
||||
continue
|
||||
elif isinstance(supports, list):
|
||||
if "thumbnail" not in supports:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
try:
|
||||
await client.post(f"{rest_base}/{target_id}", json_data=body)
|
||||
return rest_base
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_upload_result(media: dict[str, Any], *, source: str) -> dict[str, Any]:
|
||||
title = media.get("title")
|
||||
rendered_title = title.get("rendered") if isinstance(title, dict) else title
|
||||
|
||||
@@ -147,6 +147,109 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "list_navigations",
|
||||
"method_name": "list_navigations",
|
||||
"description": (
|
||||
"List wp_navigation posts (block-theme navigation menus). "
|
||||
"These power the <!-- wp:navigation --> block in Site Editor. "
|
||||
"Distinct from classic menus (use list_menus for those)."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"description": "Results per page (1-100)",
|
||||
"default": 20,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Status filter",
|
||||
"enum": ["publish", "draft", "pending", "private", "any"],
|
||||
"default": "any",
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_navigation",
|
||||
"method_name": "get_navigation",
|
||||
"description": (
|
||||
"Get a single wp_navigation post by ID, including its raw "
|
||||
"block-markup content. Use the returned content to plan an "
|
||||
"update_navigation call (e.g. fix href values inside <!-- "
|
||||
"wp:navigation-link --> blocks)."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"navigation_id": {
|
||||
"type": "integer",
|
||||
"description": "wp_navigation post ID",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["navigation_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "update_navigation",
|
||||
"method_name": "update_navigation",
|
||||
"description": (
|
||||
"Update a wp_navigation post (block-theme menu). Writes "
|
||||
"post_content (block markup) and/or title/status/slug/meta. "
|
||||
"Use this to fix navigation-link hrefs that the agent set "
|
||||
"up in Site Editor without leaving the MCP loop."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"navigation_id": {
|
||||
"type": "integer",
|
||||
"description": "wp_navigation post ID",
|
||||
"minimum": 1,
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Navigation title",
|
||||
},
|
||||
"content": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": (
|
||||
"Block markup (e.g. <!-- wp:navigation-link "
|
||||
'{"label":"About","url":"/about/"} /-->). '
|
||||
"Replaces the entire post_content."
|
||||
),
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Publication status",
|
||||
"enum": ["publish", "draft", "pending", "private"],
|
||||
},
|
||||
"slug": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Navigation slug",
|
||||
},
|
||||
"meta": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Post meta key/value map (registered keys only).",
|
||||
},
|
||||
},
|
||||
"required": ["navigation_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -401,3 +504,123 @@ class MenusHandler:
|
||||
{"error": str(e), "message": f"Failed to update menu item {item_id}: {str(e)}"},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
async def list_navigations(self, per_page: int = 20, page: int = 1, status: str = "any") -> str:
|
||||
"""List wp_navigation posts (block-theme menus)."""
|
||||
try:
|
||||
params = {"per_page": per_page, "page": page, "status": status}
|
||||
items = await self.client.get("navigation", params=params)
|
||||
|
||||
if isinstance(items, list):
|
||||
summary = [
|
||||
{
|
||||
"id": n.get("id"),
|
||||
"title": (n.get("title") or {}).get("rendered", ""),
|
||||
"slug": n.get("slug"),
|
||||
"status": n.get("status"),
|
||||
"modified": n.get("modified"),
|
||||
}
|
||||
for n in items
|
||||
]
|
||||
else:
|
||||
summary = items
|
||||
|
||||
return json.dumps(
|
||||
{"total": len(summary) if isinstance(summary, list) else 0, "navigations": summary},
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"error": str(e), "message": f"Failed to list navigations: {str(e)}"}, indent=2
|
||||
)
|
||||
|
||||
async def get_navigation(self, navigation_id: int) -> str:
|
||||
"""Get a single wp_navigation post (with raw block content)."""
|
||||
try:
|
||||
# context=edit returns content.raw so the agent can round-trip it
|
||||
params = {"context": "edit"}
|
||||
nav = await self.client.get(f"navigation/{navigation_id}", params=params)
|
||||
|
||||
content = nav.get("content") or {}
|
||||
raw_content = content.get("raw") if isinstance(content, dict) else None
|
||||
rendered = content.get("rendered") if isinstance(content, dict) else None
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"id": nav.get("id"),
|
||||
"title": (nav.get("title") or {}).get("rendered", ""),
|
||||
"slug": nav.get("slug"),
|
||||
"status": nav.get("status"),
|
||||
"content_raw": raw_content,
|
||||
"content_rendered": rendered,
|
||||
"modified": nav.get("modified"),
|
||||
"link": nav.get("link"),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": str(e),
|
||||
"message": f"Failed to get navigation {navigation_id}: {str(e)}",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
async def update_navigation(
|
||||
self,
|
||||
navigation_id: int,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
status: str | None = None,
|
||||
slug: str | None = None,
|
||||
meta: dict | None = None,
|
||||
) -> str:
|
||||
"""Update a wp_navigation post (block-theme menu)."""
|
||||
try:
|
||||
data: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
data["title"] = title
|
||||
if content is not None:
|
||||
data["content"] = content
|
||||
if status is not None:
|
||||
data["status"] = status
|
||||
if slug is not None:
|
||||
data["slug"] = slug
|
||||
if meta is not None:
|
||||
data["meta"] = meta
|
||||
|
||||
if not data:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "no_fields",
|
||||
"message": (
|
||||
"update_navigation requires at least one of "
|
||||
"title, content, status, slug, meta."
|
||||
),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
nav = await self.client.post(f"navigation/{navigation_id}", json_data=data)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"id": nav.get("id"),
|
||||
"title": (nav.get("title") or {}).get("rendered", ""),
|
||||
"slug": nav.get("slug"),
|
||||
"status": nav.get("status"),
|
||||
"modified": nav.get("modified"),
|
||||
"link": nav.get("link"),
|
||||
"message": f"Navigation {navigation_id} updated successfully",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": str(e),
|
||||
"message": f"Failed to update navigation {navigation_id}: {str(e)}",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@@ -292,7 +292,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
{
|
||||
"name": "update_page",
|
||||
"method_name": "update_page",
|
||||
"description": "Update an existing WordPress page. Can update title, content, status, slug, and parent page.",
|
||||
"description": "Update an existing WordPress page. Can update title, content, status, slug, parent page, featured image, and post meta (e.g. Yoast fields).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -322,6 +322,14 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Parent page ID",
|
||||
},
|
||||
"featured_media": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Featured image attachment ID. Pass 0 to clear.",
|
||||
},
|
||||
"meta": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Post meta key/value map forwarded as REST `meta` (e.g. Yoast fields like `_yoast_wpseo_metadesc`). Only registered/whitelisted keys are persisted by WordPress.",
|
||||
},
|
||||
},
|
||||
"required": ["page_id"],
|
||||
},
|
||||
@@ -892,6 +900,8 @@ class PostsHandler:
|
||||
status: str | None = None,
|
||||
slug: str | None = None,
|
||||
parent: int | None = None,
|
||||
featured_media: int | None = None,
|
||||
meta: dict | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Update an existing WordPress page.
|
||||
@@ -903,6 +913,8 @@ class PostsHandler:
|
||||
status: Publication status
|
||||
slug: Page URL slug
|
||||
parent: Parent page ID
|
||||
featured_media: Featured image attachment ID (0 clears it)
|
||||
meta: Post meta key/value map (e.g. Yoast fields)
|
||||
|
||||
Returns:
|
||||
JSON string with updated page data
|
||||
@@ -920,6 +932,10 @@ class PostsHandler:
|
||||
data["slug"] = slug
|
||||
if parent is not None:
|
||||
data["parent"] = parent
|
||||
if featured_media is not None:
|
||||
data["featured_media"] = featured_media
|
||||
if meta is not None:
|
||||
data["meta"] = meta
|
||||
|
||||
page = await self.client.post(f"pages/{page_id}", json_data=data)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user