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:
2026-05-20 23:33:20 +02:00
parent f203ca88de
commit 43fd2201a0
223 changed files with 36183 additions and 4115 deletions

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -119,7 +119,9 @@ class WordPressPlugin(BasePlugin):
else:
self.wp_cli = None
# Note: Database, Bulk, and System operations moved to wordpress_advanced plugin
# Database, bulk, and system operations live on
# ``wordpress_specialist`` (companion-backed; no Docker socket).
# The legacy ``wordpress_advanced`` plugin was sunset 2026-05-04.
@staticmethod
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -156,11 +158,12 @@ class WordPressPlugin(BasePlugin):
# Advanced content handlers
specs.extend(handlers.get_seo_specs()) # 4 tools
specs.extend(handlers.get_menus_specs()) # 5 tools
specs.extend(handlers.get_menus_specs()) # 9 tools (incl. wp_navigation CRUD)
specs.extend(handlers.get_wp_cli_specs()) # 15 tools
# Note: WooCommerce specs moved to woocommerce plugin (Phase D.1)
# Note: Database, Bulk, and System specs in wordpress_advanced plugin
# Database / bulk / system specs live on wordpress_specialist
# (companion-backed; legacy wordpress_advanced was sunset 2026-05-04).
return specs
@@ -479,6 +482,15 @@ class WordPressPlugin(BasePlugin):
async def update_menu_item(self, **kwargs):
return await self.menus.update_menu_item(**kwargs)
async def list_navigations(self, **kwargs):
return await self.menus.list_navigations(**kwargs)
async def get_navigation(self, **kwargs):
return await self.menus.get_navigation(**kwargs)
async def update_navigation(self, **kwargs):
return await self.menus.update_navigation(**kwargs)
# === WP-CLI Operations ===
async def wp_cache_flush(self, **kwargs):
if self.wp_cli:
@@ -555,7 +567,8 @@ class WordPressPlugin(BasePlugin):
return await self.wp_cli.wp_core_update(**kwargs)
return '{"error": "WP-CLI not available. Container not configured."}'
# Note: Database, Bulk, and System operations moved to wordpress_advanced plugin
# Database, bulk, and system operations live on wordpress_specialist
# (companion-backed; legacy wordpress_advanced was sunset 2026-05-04).
# === Legacy compatibility methods ===
# These methods are kept for potential backward compatibility

View File

@@ -30,7 +30,8 @@ from plugins.wordpress.schemas.product import (
)
from plugins.wordpress.schemas.seo import SEOData, SEOUpdate
# Note: Database, Bulk, and System schemas moved to wordpress_advanced plugin
# Database / bulk / system schemas live on wordpress_specialist
# (companion-backed; legacy wordpress_advanced was sunset 2026-05-04).
__all__ = [
# Common
@@ -64,5 +65,4 @@ __all__ = [
# SEO
"SEOData",
"SEOUpdate",
# Note: Database, Bulk, and System schemas moved to wordpress_advanced plugin
]