docs: mapeamento completo do EMCP Tools (11 docs, ~6070 linhas)
Especificação funcional do plugin emcp-tools v3.12.1 (msrbuilds/elementor-mcp, GPL-2.0-or-later) via leitura directa do código-fonte em emanuelalmeida.pt. - 00: arquitectura (bootstrap, ability registrar, dispatcher, MCP adapter) - 01: Elementor classico (paginas, layout, widgets, templates, globals) - 02: Elementor Atomic v4 + Gutenberg - 03: WordPress core (conteudo, media, settings, temas) - 04: Themer (CPT, condicoes, render, PHP templates) - 05: Redirects + change ledger unificado (rollback) - 06: Sandbox PHP snippets + custom widgets - 07: Filesystem/DB/WP-CLI/Security/Performance (maior risco) - 08: Integracoes terceiros (ACF, Meta Box, forms, SEO) - 09: Stock images + Cloud + OAuth - 10: Sistema de modulos + inventario Pro-only (30 classes) - INDEX: sintese, sequencia de construcao, tabela de risco Produzido por 10 subagentes code-explorer em paralelo + revisao cruzada de consistencia. Cada doc inclui blueprint de replica (copiar/simplificar/omitir).
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
# 00 — Arquitectura do EMCP Tools e blueprint da réplica
|
||||
|
||||
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
|
||||
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
|
||||
19-08-2026. Autor terceiro: Mian Shahzad Raza (`msrbuilds.com`), repo público
|
||||
`github.com/msrbuilds/elementor-mcp`, licença **GPL-2.0-or-later**.
|
||||
|
||||
## 0. Achado mais importante: a peça MCP não é do fornecedor — é do WordPress core team
|
||||
|
||||
O plugin **não implementa o protocolo MCP**. Depende de duas bibliotecas Composer, ambas
|
||||
**projectos oficiais da equipa de IA do WordPress.org** (`WordPress AI Team`,
|
||||
`make.wordpress.org/ai`), GPL-2.0-or-later, públicas:
|
||||
|
||||
| Pacote | Namespace PHP | Repo | O que faz |
|
||||
|---|---|---|---|
|
||||
| `wordpress/mcp-adapter` | `WP\MCP\` | `github.com/wordpress/mcp-adapter` | Expõe abilities da Abilities API como servidor MCP (`McpAdapter`, `McpServer`, `HttpTransport`, `SessionManager`, `RequestRouter`, `HttpSessionValidator`, `McpErrorFactory`) — cria o endpoint REST `/wp-json/mcp/{route}` |
|
||||
| `wordpress/php-mcp-schema` | `WP\McpSchema\` | `github.com/WordPress/php-mcp-schema` | DTOs PHP para o schema oficial do protocolo MCP |
|
||||
|
||||
A **Abilities API em si é núcleo do WordPress 6.9+/7.0** (`wp_register_ability()`,
|
||||
`wp_get_ability()`, `wp_get_abilities()`, hooks `wp_abilities_api_categories_init` /
|
||||
`wp_abilities_api_init`) — não é código do plugin nem do adapter.
|
||||
|
||||
**Consequência directa para a réplica:** não há nada de protocolo para reimplementar.
|
||||
`composer require wordpress/mcp-adapter` (ou vendorizar uma cópia, como o EMCP faz — ver §1)
|
||||
dá-nos a mesma máquina JSON-RPC/sessão/transporte. O trabalho real do "clone" é: (a) registar
|
||||
as nossas próprias abilities via `wp_register_ability()`, com o nosso próprio prefixo (ex.
|
||||
`descomplicar-tools/*`), e (b) `$mcp_adapter->create_server(...)` com o nosso `server_id`. Tudo o
|
||||
resto — catálogo de ~165 tools, módulos, sandbox, change-ledger, content-mirror — é trabalho de
|
||||
aplicação, não de protocolo.
|
||||
|
||||
**Nota de licenciamento:** por ser GPL-2.0-or-later, nada impede legalmente copiar/adaptar o
|
||||
código-fonte do EMCP Tools directamente para um plugin próprio (fork), mantendo a licença GPL a
|
||||
jusante. "Réplica 100% nossa" pode ser lida como (a) fork com rebranding + remoção do Freemius +
|
||||
namespace próprio (muito menos esforço, reutiliza ~78k linhas já testadas), ou (b) reimplementação
|
||||
limpa a partir desta especificação (mais esforço, zero dependência de código de terceiros,
|
||||
liberdade total de redesenho). Esta série de documentos serve ambos os caminhos — é a
|
||||
especificação funcional; a decisão de fork-vs-reescrita é independente dela.
|
||||
|
||||
## 1. Estrutura de directórios do plugin
|
||||
|
||||
```
|
||||
emcp-tools/
|
||||
├── emcp-tools.php # bootstrap: header, guarda Free⇄Pro, Freemius, handoff
|
||||
├── includes/
|
||||
│ ├── class-bootstrap.php # boot(): dependências, carrega classes, arranca Plugin
|
||||
│ ├── class-plugin.php # EMCP_Tools_Plugin (singleton orquestrador) — ver §3
|
||||
│ ├── class-mcp-adapter-bootstrap.php # preload do WP\MCP\ vendorizado — ver §2
|
||||
│ ├── class-schema-compat.php # emcp_tools_register_ability() + normalização de schema
|
||||
│ ├── abilities/
|
||||
│ │ ├── class-ability-registrar.php # regista TODOS os grupos de abilities — índice mestre
|
||||
│ │ ├── class-dispatcher-abilities.php # compact tool mode (list-tools/get-tool-schema/call-tool)
|
||||
│ │ ├── class-{grupo}-abilities.php # ~40 classes de abilities (uma por grupo funcional)
|
||||
│ │ ├── forms/class-{plugin}-integration.php # CF7 (free) + dispatcher genérico
|
||||
│ │ └── seo/class-{plugin}-integration.php # SlimSEO (free) + dispatcher genérico
|
||||
│ ├── modules/ # os 9 módulos opcionais + registry — ver doc 10
|
||||
│ ├── themer/ # Themer CPT/render/condições/blocos/widgets/PHP — ver doc 04
|
||||
│ ├── sandbox/ # armazenamento de artefactos sandbox (bundle/paths/store) — ver doc 06
|
||||
│ ├── security/ # 4 auditores + finding — ver doc 07
|
||||
│ ├── performance/ # 3 auditores + finding — ver doc 07
|
||||
│ ├── redirects/ # handler + store — ver doc 05
|
||||
│ ├── cloud/ # ligação/sync EMCP Cloud — ver doc 09
|
||||
│ ├── oauth/ # servidor OAuth para autenticação MCP remota — ver doc 09
|
||||
│ ├── wpcli/ # runner/validator/jobs para run-wp-cli — ver doc 07
|
||||
│ ├── widgets/ # catálogos de widgets Elementor (free/woo/pro) — ver doc 02
|
||||
│ ├── blocks-catalog/ # catálogos Kadence Blocks / Spectra — ver doc 03
|
||||
│ ├── schemas/ # gerador de JSON Schema a partir de controls Elementor
|
||||
│ ├── validators/ # validador de settings/elementos
|
||||
│ ├── admin/ # UI de admin: class-admin.php (6115 linhas!), views/*, mcpb-builder
|
||||
│ └── vendors/fremius/ # SDK Freemius (licenciamento/billing) — ~50k linhas, IRRELEVANTE p/ réplica
|
||||
├── vendor/wordpress/ # mcp-adapter + php-mcp-schema vendorizados (ver §0)
|
||||
├── vendor/enshrined/svg-sanitize/ # sanitização SVG (módulo svg-support)
|
||||
├── vendor/automattic/jetpack-autoloader/ # autoloader partilhado entre plugins que usam o adapter
|
||||
└── prompts/, assets/, bin/, languages/
|
||||
```
|
||||
|
||||
**Total:** ~128 465 linhas PHP fora de `vendor/`/`languages/` (medido 19-08-2026); ~50k dessas
|
||||
são o SDK Freemius bundled (irrelevante para a réplica — ver §6). Ficheiro maior de longe:
|
||||
`includes/admin/class-admin.php`, **6115 linhas** (catálogo de tools para a UI, aplicação do
|
||||
deny-list incremental de 34 versões, todas as views de admin — ver doc dedicado se necessário).
|
||||
|
||||
## 2. Cadeia de arranque (ordem real de hooks)
|
||||
|
||||
```
|
||||
emcp-tools.php carrega no arquivo (topo do request)
|
||||
↓ guarda Free⇄Pro (§7), depois Freemius init
|
||||
↓ require class-bootstrap.php
|
||||
add_action('plugins_loaded', [Bootstrap, 'boot'], 20)
|
||||
↓
|
||||
plugins_loaded:20 → EMCP_Tools_Bootstrap::boot()
|
||||
↓ EMCP_Tools_Adapter_Bootstrap::ensure() — preload do WP\MCP\ vendorizado (ver §2.1)
|
||||
↓ require de todas as classes includes/**/*.php
|
||||
↓ EMCP_Tools_Plugin::instance() → init()
|
||||
add_action('wp_abilities_api_categories_init', register_category)
|
||||
add_action('wp_abilities_api_init', register_abilities) # regista ~165 abilities
|
||||
add_action('mcp_adapter_init', register_mcp_server, 20) # cria o servidor MCP
|
||||
add_filter('emcp_tools_ability_names', filter_disabled_tools) # aplica o deny-list
|
||||
add_filter('rest_pre_dispatch', MCP_Host_Guard::guard, 5) # valida Host header
|
||||
add_filter('rest_pre_dispatch'/'rest_post_dispatch', mcp_log_*) # log de pedidos MCP
|
||||
↓
|
||||
(Abilities API é lazy — só inicializa no primeiro wp_get_ability())
|
||||
O PRÓPRIO adapter, ao arrancar (mcp_adapter_init prioridade 10), chama wp_get_ability()
|
||||
internamente para descobrir tools → dispara wp_abilities_api_init → register_abilities() corre
|
||||
ANTES do nosso register_mcp_server (prioridade 20) — por isso $this->ability_names já está
|
||||
preenchido quando register_mcp_server lê $this->ability_names.
|
||||
↓
|
||||
mcp_adapter_init:20 → EMCP_Tools_Plugin::register_mcp_server($mcp_adapter)
|
||||
→ $mcp_adapter->create_server('emcp-tools-server', 'mcp', 'emcp-tools-server', …, $tools, …)
|
||||
→ endpoint fica disponível em /wp-json/mcp/emcp-tools-server
|
||||
```
|
||||
|
||||
### 2.1 O preload "authoritative namespace" (`class-mcp-adapter-bootstrap.php`)
|
||||
|
||||
Problema real documentado no código (issue #99): quando **vários plugins activos** vendorizam
|
||||
`wordpress/mcp-adapter` (ex. WooCommerce + EMCP Tools) em versões diferentes, e nem todos usam o
|
||||
mesmo autoloader partilhado (Jetpack Autoloader), PHP carrega **classes individuais** de cópias
|
||||
diferentes consoante qual plugin referencia cada classe primeiro — resultado: um `McpAdapter` da
|
||||
v0.5.0 a par de um `HttpTransport` da v0.4.1 no mesmo request ("sheared namespace"), que falha
|
||||
com `McpServerError: Session terminated` (JSON-RPC -32600).
|
||||
|
||||
**Mitigação do EMCP:** regista um autoloader `spl_autoload_register($cb, true, true)` (throw,
|
||||
**prepend**) logo no arranque, servindo TODO o namespace `WP\MCP\` a partir da SUA cópia
|
||||
vendorizada — antes que qualquer outro plugin possa referenciar uma classe do adapter. Reafirma-se
|
||||
depois de carregar o Jetpack Autoloader do próprio adapter (que também se regista com prepend).
|
||||
|
||||
**Implicação para a réplica:** se formos "mais um plugin" a vendorizar `wordpress/mcp-adapter`
|
||||
lado a lado com EMCP Tools/WooCommerce no mesmo WordPress, herdamos este mesmo risco de colisão.
|
||||
Duas opções limpas: (a) usar o Jetpack Autoloader nós também (para participar na arbitragem de
|
||||
versão-mais-alta em vez de competir com autoloaders simples), ou (b) não vendorizar — depender de
|
||||
uma cópia `wordpress/mcp-adapter` partilhada ao nível do site (plugin dedicado só ao adapter,
|
||||
todos os outros plugins declaram a dependência) — mais correcto a prazo mas exige coordenação com
|
||||
todos os plugins que hoje bundlam a sua própria cópia.
|
||||
|
||||
## 3. `EMCP_Tools_Plugin` — orquestrador singleton
|
||||
|
||||
Ficheiro: `includes/class-plugin.php`. Contrato mínimo para a réplica:
|
||||
|
||||
- **`register_category()`** — `wp_register_ability_category('<prefixo>', ['label'=>…,
|
||||
'description'=>…])`, chamado em `wp_abilities_api_categories_init`.
|
||||
- **`register_abilities()`** — `$this->ability_names = $registrar->register_all($elementor_active)`,
|
||||
chamado em `wp_abilities_api_init`. `$elementor_active` vem de
|
||||
`EMCP_Tools_Bootstrap::elementor_active()` (guarda de dependência — grupos Elementor-dependentes
|
||||
ficam todos atrás desta flag, ver doc 01/02 e a lista completa em `class-ability-registrar.php`
|
||||
reproduzida no doc 10).
|
||||
- **`get_active_ability_names()`** — força a inicialização lazy da Abilities API
|
||||
(`wp_get_ability('emcp-tools/list-pages')` como gatilho) e devolve o array já filtrado pelo
|
||||
deny-list; usado pelo dispatcher (compact mode) e por qualquer superfície externa (ex. um chat
|
||||
de admin) que precise de saber "o que está mesmo disponível agora".
|
||||
- **`filter_disabled_tools($names)`** — `array_values(array_diff($names,
|
||||
get_option('emcp_tools_disabled_tools', [])))`, hook em `emcp_tools_ability_names`. **Este é o
|
||||
único ponto de aplicação do deny-list** — mecanismo do deny-list incremental (34 migrações) já
|
||||
documentado em `skill://emcp-tools` §8; aqui confirma-se o hook exacto que o aplica.
|
||||
- **`register_mcp_server($mcp_adapter)`** — chamado em `mcp_adapter_init` prioridade 20 (depois da
|
||||
Abilities API já ter corrido). Comportamento condicional:
|
||||
- **Server gate** (`emcp_tools_server_enabled`, on por omissão): se off, abilities continuam
|
||||
registadas no core mas **nenhum endpoint MCP é criado** — kill-switch total sem desregistar
|
||||
nada.
|
||||
- **Compact tool mode** (`emcp_tools_dispatcher_mode`, off por omissão): quando ligado, o
|
||||
`$tools` passado a `create_server()` é só os 3 nomes do dispatcher
|
||||
(`emcp-tools/{list-tools,get-tool-schema,call-tool}`) — o resto continua **registado e
|
||||
invocável via `call-tool`**, só não aparece em `tools/list`. Quando desligado, `$tools` é a
|
||||
lista cheia (+ as 3 abilities de contexto do core: `core/get-site-info`,
|
||||
`core/get-user-info`, `core/get-environment-info`, sempre incluídas quando existirem).
|
||||
- `create_server()` recebe: `server_id='emcp-tools-server'`, `route_namespace='mcp'`,
|
||||
`route='emcp-tools-server'` (→ endpoint final `/wp-json/mcp/emcp-tools-server`), nome/descrição
|
||||
(descrição compõe um resumo do ambiente do site via `EMCP_Tools_Site_Context`), versão,
|
||||
`transports=[HttpTransport::class]`, `tools=$tools`, `resources=[]`, `prompts=[]`, e um
|
||||
`transport_permission_callback` opcional: se o módulo OAuth (`EMCP_Tools_OAuth_Server`) está
|
||||
activo, usa Bearer OAuth; senão cai no default do adapter (Application Password / cookie
|
||||
admin).
|
||||
|
||||
## 4. Compact tool mode — o dispatcher de 3 tools
|
||||
|
||||
`includes/abilities/class-dispatcher-abilities.php` (ver excerto completo lido nesta sessão).
|
||||
Sempre **registado** (para `wp_get_ability()` resolver os 3 nomes), mas só **exposto no servidor**
|
||||
quando `emcp_tools_dispatcher_mode=1` (§3). Este é exactamente o padrão que os 3 servidores
|
||||
`emcp-tools`/`emcp-emanuelalmeida`/`emcp-descomplicar` deste ecossistema já usam pelo lado do
|
||||
cliente (system prompt: "Compact tool mode... Discover tools with list-tools, fetch inputs with
|
||||
get-tool-schema, run with call-tool").
|
||||
|
||||
| Tool | O que faz | Enforcement |
|
||||
|---|---|---|
|
||||
| `list-tools` | Devolve `{name, description, category, destructive}` para cada ability activa (pós-deny-list), filtrável por `search`/`category`. Inclui `context` (resumo do ambiente via `Site_Context`). | `permission_callback`: `current_user_can('edit_posts')` — é só metadata/routing |
|
||||
| `get-tool-schema` | Devolve `{description, inputSchema}` por nome, em lote (`names: string[]`). Nomes fora do set activo entram em `unavailable`. | idem |
|
||||
| `call-tool` | Resolve `wp_get_ability($name)`, corre `check_permissions($args)` **da ability alvo** (nunca do dispatcher), depois `validate_input()` se existir, depois `execute($args)`. | **A gate real é sempre a da tool alvo** — o dispatcher nunca contorna `permission_callback` individual |
|
||||
|
||||
**Blueprint para a réplica:** replicar este padrão exactamente — um dispatcher de 3 tools que só
|
||||
resolve/chama `wp_get_ability()`, nunca duplica lógica de permissão. É ~250 linhas triviais de
|
||||
reescrever do zero (schema simples, sem estado) — não vale a pena tentar simplificar mais.
|
||||
|
||||
## 5. `emcp_tools_register_ability()` — o único ponto de entrada de registo
|
||||
|
||||
`includes/class-schema-compat.php`. Toda classe de abilities chama esta função global (nunca
|
||||
`wp_register_ability()` directamente). Faz 3 coisas antes de delegar:
|
||||
|
||||
1. **`sanitize()`** — normaliza o JSON Schema para clientes exigentes: remove valores vazios de
|
||||
`enum`, força `properties: {}` (objecto, nunca array vazio) — Gemini/Antigravity rejeitam a
|
||||
forma "errada". Recursivo em `items`/`allOf`/`oneOf`/`anyOf`.
|
||||
2. **`strictify()`** *(opt-in, `emcp_tools_strict_schemas`, off por omissão)* — reescreve o schema
|
||||
para o modo "strict function calling" da OpenAI: toda a propriedade em `required`, as
|
||||
originalmente opcionais tornam-se nullable, `additionalProperties: false` em objectos com
|
||||
propriedades declaradas. Existe porque CrewAI e stacks OpenAI-compatíveis exigem esta forma; um
|
||||
schema "normal" (opcionais fora de `required`) é rejeitado por eles.
|
||||
3. **`wrap_execute_callback()`** — envolve o callback da ability em dois cuidados:
|
||||
- **Veto de escrita** (`emcp_tools_before_write` filter, só para abilities não-read-only):
|
||||
um listener pode devolver `WP_Error` para bloquear a escrita antes de correr — mecanismo real
|
||||
de enforcement de guardrails (usado pela feature Pro "Memory Enforcer"). Seam útil para nós:
|
||||
dá um ponto único onde qualquer política de governança (ex. CARL) pode vetar uma escrita MCP
|
||||
sem tocar em cada ability individualmente.
|
||||
- **`normalize_result()`** — garante que o retorno cabe em `structuredContent` do MCP (que o
|
||||
schema tipa como objecto `{[key:string]:unknown}`, nunca lista). Arrays associativos e
|
||||
objectos passam直; listas/escalares/`null` são embrulhados em `{data: …}`; `array()` vazio
|
||||
TAMBÉM é embrulhado (PHP não distingue lista vazia de mapa vazio — desembrulhado serializa
|
||||
para `[]`, forma inválida). **Bug real que isto evita:** a integração WooCommerce devolve
|
||||
`WP_REST_Response::get_data()` verbatim, e várias rotas `wc/v3` respondem com array de topo
|
||||
(ex. `report-products-totals`) — sem este wrapper, clientes MCP estritos rejeitam a resposta.
|
||||
|
||||
**Blueprint para a réplica:** replicar exactamente este envelope (sanitize + normalize_result no
|
||||
mínimo; strictify só se formos alvo de clientes OpenAI-strict). É a peça que evita uma classe
|
||||
inteira de bugs "funciona no Claude, parte no CrewAI" — não pular esta camada achando-a
|
||||
acessória.
|
||||
|
||||
## 6. Guardas de infraestrutura (fora do fluxo funcional, mas obrigatórias)
|
||||
|
||||
- **`EMCP_Tools_MCP_Host_Guard::guard`** (`rest_pre_dispatch`, prioridade 5) — recusa pedidos MCP
|
||||
cujo header `Host` já não bate com o `home` do site (conector apontado para um domínio
|
||||
antigo/temporário). `no_store_headers` (`rest_pre_serve_request`) força `Cache-Control:
|
||||
no-store` nas respostas MCP — proxies como LiteSpeed/QUIC descartam respostas suspeitas de cache
|
||||
sem isto (issue nomeada no código).
|
||||
- **`EMCP_Tools_MCP_Request_Log`** — grava tool/status/duração/`x-request-id` de cada pedido MCP
|
||||
via `rest_pre_dispatch`(6)/`rest_post_dispatch`(10), só para rotas que começam por
|
||||
`/mcp/emcp-tools-server` — alimenta a tab "MCP Log" do admin.
|
||||
|
||||
## 7. Guarda Free⇄Pro (single-instance)
|
||||
|
||||
`emcp-tools.php`, topo do ficheiro (antes de qualquer `require`). Free e Pro são **o mesmo código**
|
||||
em duas pastas de plugin (`emcp-tools/` vs `emcp-pro/`), distinguidas só por um marcador
|
||||
`.emcp-pro` no directório. Como WordPress trata as duas pastas como plugins distintos,
|
||||
`require_once` não deduplica entre elas — activar ambas redeclara todas as classes → fatal error.
|
||||
A guarda corre **antes de qualquer require**, detecta se já correu neste request
|
||||
(`defined('EMCP_TOOLS_VERSION')`) e decide: Premium sempre ganha — a cópia Free cede (mostra aviso,
|
||||
`return` antes de declarar nada) se a Premium estiver activa; a cópia Premium desactiva a Free se
|
||||
esta já tiver arrancado primeiro. **Não relevante para a réplica** (é housekeeping específico do
|
||||
modelo de distribuição Free/Pro deste vendor) — mas explica porque é seguro assumir que só UMA
|
||||
"visão" do código corre por request, mesmo no build Free.
|
||||
|
||||
## 8. Superfície total (dimensionamento)
|
||||
|
||||
Ver `class-ability-registrar.php::register_groups()` para a lista EXACTA de classes/condições —
|
||||
reproduzida integralmente no doc 10 (módulos) como referência cruzada, porque a ordem de registo
|
||||
ali É a fonte de verdade de que grupo depende de que módulo/plugin/licença. Resumo por doc:
|
||||
|
||||
| Doc | Assunto | Nº aprox. de abilities |
|
||||
|---|---|---|
|
||||
| 01 | Elementor clássico (páginas, layout, widgets, templates, globals, custom code, composite) | ~35 |
|
||||
| 02 | Elementor Atomic v4 + Gutenberg | ~25 |
|
||||
| 03 | WordPress core (conteúdo, media, settings, plugins, temas, users, menus) + temas/frameworks | ~30 |
|
||||
| 04 | Themer (CPT header/footer/single/archive/search/404 + Themer PHP) | ~13 |
|
||||
| 05 | Redirects + Search + Snapshot + Change-ledger + Content-mirror | ~14 |
|
||||
| 06 | Sandbox (PHP snippets, custom widgets/blocks, cloud bundle) | ~20 |
|
||||
| 07 | Filesystem + DB + WP-CLI + Security + Performance | ~15 |
|
||||
| 08 | Integrações terceiros (ACF, Meta Box, forms, SEO) | ~30 |
|
||||
| 09 | Stock images + Cloud sync + OAuth | ~17 |
|
||||
| 10 | Módulos (lifecycle) + inventário Pro-only (metadata) | n/a (estrutural) |
|
||||
|
||||
Total bate aproximadamente com os 162-165 confirmados ao vivo em `skill://emcp-tools` §7 — a
|
||||
diferença entre sites reflecte quais integrações de terceiros estão de facto instaladas.
|
||||
|
||||
## Fonte
|
||||
|
||||
Leitura directa (19-08-2026) de: `emcp-tools.php`, `includes/class-mcp-adapter-bootstrap.php`,
|
||||
`includes/class-plugin.php`, `includes/class-schema-compat.php`,
|
||||
`includes/abilities/class-ability-registrar.php`,
|
||||
`includes/abilities/class-dispatcher-abilities.php`,
|
||||
`vendor/wordpress/mcp-adapter/composer.json`, `vendor/wordpress/php-mcp-schema/composer.json`;
|
||||
inventário completo de ficheiros (`find … -name '*.php' -exec wc -l`, 128 465 linhas). Cruzado com
|
||||
`skill://emcp-tools` (auditoria de postura de segurança, 16-08-2026) para os números de abilities
|
||||
activas/desligadas por site.
|
||||
@@ -0,0 +1,632 @@
|
||||
# 01 — Elementor Clássico (não-atómico): páginas, layout, widgets, templates, globals, custom code, composite
|
||||
|
||||
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
|
||||
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
|
||||
19-08-2026. Cobre as classes de abilities que operam sobre o modelo de dados Elementor
|
||||
**clássico** (`_elementor_data`, árvore `container`/`widget`/`section`/`column`), incluindo os
|
||||
grupos de leitura/escrita das **Global Classes** do Elementor 4.0+ (que já não são "clássicas"
|
||||
mas vivem no mesmo `_elementor_data`/kit e não fazem parte do sistema de props atómicas
|
||||
tratado no doc 02). Todas as classes aqui descritas registam-se dentro do bloco
|
||||
`if ( $elementor_active )` de `class-ability-registrar.php` (linha ~452), sem nenhum outro gate
|
||||
de módulo — só o Elementor (free) precisa de estar activo.
|
||||
|
||||
## ⚠️ Nota importante para a batch — onde vive o CRUD de widgets custom
|
||||
|
||||
`class-widget-abilities.php` (`EMCP_Tools_Widget_Abilities`) **NÃO contém** nenhuma tool de
|
||||
criação/edição/eliminação de widgets custom (`create-custom-widget`, `update-custom-widget`,
|
||||
`get-custom-widget`, `list-custom-widgets`, `set-widget-status`, `delete-custom-widget` — a
|
||||
lista de 16 tools "Widget/Block Builder" identificada em `skill://emcp-tools` §2.14). Esta
|
||||
classe cobre **só colocação/actualização de instâncias de widget numa página** — três tools:
|
||||
`add-free-widget`, `add-pro-widget` (ambas catalog-backed, inserem um widget *já existente* no
|
||||
registo do Elementor) e `update-widget` (edita definições de uma instância já colocada). O CRUD
|
||||
de definição de widgets custom (a "fábrica" que cria um NOVO tipo de widget PHP/JS a partir de
|
||||
um spec) vive noutro módulo — **confirmado por grep ao `class-ability-registrar.php`, não faz
|
||||
parte de nenhuma das 10 classes desta tarefa** — quase certamente no sandbox de widgets/blocos
|
||||
custom tratado no doc 06 (`EMCP_Tools_Sandbox_*`). Doc06 deve confirmar isto ao ler o registrar
|
||||
completo; aqui fica o achado negativo registado para não haver dupla cobertura nem lacuna.
|
||||
|
||||
---
|
||||
|
||||
## 1. `EMCP_Tools_Page_Abilities` — `includes/abilities/class-page-abilities.php`
|
||||
|
||||
**Condição de registo:** dentro de `if ( $elementor_active )`, sempre (sem gate adicional).
|
||||
Construtor recebe `EMCP_Tools_Data $data` e `EMCP_Tools_Element_Factory $factory` (injectados
|
||||
pelo registrar). 5 tools, todas prefixadas `emcp-tools/`.
|
||||
|
||||
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|
||||
|---|---|---|---|---|
|
||||
| `create-page` | `title*` (string), `status` (`draft`\|`publish`, default draft), `post_type` (`page`\|`post`, default page), `template` (slug), `content` (array de elementos, opcional) | `wp_insert_post()` com `_elementor_edit_mode=builder` + `_elementor_template_type=wp-{post_type}`; grava `content` (ou `[]` se omitido) via `EMCP_Tools_Data::save_page_data()`; devolve `post_id`, `edit_url`, `preview_url`. | `check_create_permission`: `publish_pages` \|\| `edit_pages` | não-readonly, não-destructive, não-idempotente |
|
||||
| `update-page-settings` | `post_id*`, `settings*` (objecto livre) | Delegado 1:1 a `EMCP_Tools_Data::save_page_settings()` — grava definições ao nível de página (background, padding, custom CSS, layout) via `Document::save(['settings'=>...])` com fallback a merge em `_elementor_page_settings`. | `check_edit_permission`: `edit_posts` + (se `post_id`) `edit_post` desse post | não-readonly, não-destructive, idempotente |
|
||||
| `delete-page-content` | `post_id*` | `save_page_data($post_id, [])` — **limpa TODO o conteúdo Elementor da página**, mantendo a página em si (post continua a existir). | `check_delete_permission`: `edit_posts` **E** `delete_posts`, mais (se `post_id`) `edit_post` **E** `delete_post` desse post — a única classe do doc que exige capability de eliminação para uma operação que tecnicamente só edita meta, porque o efeito é irreversível sem o change-ledger | não-readonly, **destructive**, idempotente |
|
||||
| `import-template` | `post_id*`, `template_json*` (array de elementos Elementor), `position` (default -1 = append) | Lê a página actual, `reassign_ids()` a todo o `template_json` (evita colisão de IDs), insere no array (append ou `array_splice` na posição) e grava. Devolve `elements_count` (contagem recursiva via `count_elements()`). | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
|
||||
| `export-page` | `post_id*` | Devolve `get_page_data($post_id)` verbatim como `json` — export completo da árvore Elementor da página, reimportável via `import-template`/`apply-template`. | `check_edit_permission` | **readonly**, idempotente |
|
||||
|
||||
**Achado de design:** `create-page` grava sempre `_elementor_data` mesmo quando `content` é
|
||||
omitido (`save_page_data($post_id, [])`) — isto **inicializa** a meta em vez de a deixar por
|
||||
criar, o que é relevante porque `EMCP_Tools_Data::get_page_data()` trata "meta ausente" e "meta
|
||||
`[]`" da mesma forma (array vazio), mas só a segunda garante que o Elementor reconhece a página
|
||||
como "Editada com Elementor" desde o primeiro save.
|
||||
|
||||
---
|
||||
|
||||
## 2. `EMCP_Tools_Layout_Abilities` — `includes/abilities/class-layout-abilities.php`
|
||||
|
||||
**Condição de registo:** dentro de `if ( $elementor_active )`, sempre. Mesmo par de
|
||||
dependências injectadas (`$data`, `$factory`). 9 tools — o grupo com mais ferramentas do
|
||||
Elementor clássico, cobrindo toda a manipulação estrutural da árvore de elementos.
|
||||
|
||||
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|
||||
|---|---|---|---|---|
|
||||
| `add-container` | `post_id*`, `parent_id` (vazio = topo), `position` (-1=append), `settings` (flex/grid completo), `full_bleed` (bool) | Cria um `container` via `EMCP_Tools_Element_Factory::create_container()` e insere na árvore. `full_bleed=true` faz merge do preset `full_bleed_preset()` (content_width=full, width 100%, padding/gap zero, column+stretch) **antes** de aplicar `settings` do chamador (que sempre ganham). **Bloqueia** com erro accionável se `EMCP_Tools_Atomic_Props::is_container_supported()` for falso (ver "Gotchas" abaixo). | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
|
||||
| `update-container` | `post_id*`, `element_id*`, `settings*` (merge parcial) | Valida que o elemento alvo é mesmo um container (`is_container_type()`) antes de aplicar `update_element_settings()` — devolve erro `not_container` se for widget (aponta para `update-widget`). | `check_edit_permission` | não-readonly, não-destructive, idempotente |
|
||||
| `update-element` | `post_id*`, `element_id*`, `settings*` | Versão **universal** de update — funciona em qualquer elType, container ou widget, sem o caller precisar de saber qual é. Aceita também `styles`/`editor_settings` no payload (roteados para a raiz do elemento pela camada de dados — ver §Elementor_Data). Recomendado como default em vez de `update-container`/`update-widget` separados. | `check_edit_permission` | não-readonly, não-destructive, idempotente |
|
||||
| `batch-update` | `post_id*`, `operations*` (array de `{element_id, settings}`) | Aplica múltiplos updates **num único save** (uma leitura + uma escrita da página inteira) — muito mais eficiente que N chamadas a `update-element`. Continua a processar mesmo com falhas parciais; devolve `{success, updated, failed:[{element_id,reason}]}`. | `check_edit_permission` | não-readonly, não-destructive, idempotente |
|
||||
| `set-element-label` | `post_id*`, `element_id*`, `title*` | Wrapper de conveniência sobre `update_element_settings()` com `editor_settings.title` — define só o rótulo do Navigator (útil sobretudo em elementos atómicos v4, mas funciona em qualquer elType). | `check_edit_permission` | não-readonly, não-destructive, idempotente |
|
||||
| `reorder-elements` | `post_id*`, `container_id*`, `element_ids*` (ordem desejada) | Reordena os filhos DIRECTOS de um container. Valida que todos os `element_ids` são de facto filhos directos (erro se não). Filhos existentes não mencionados na lista são acrescentados no fim (preservados, não perdidos). | `check_edit_permission` | não-readonly, não-destructive, idempotente |
|
||||
| `move-element` | `post_id*`, `element_id*`, `target_parent_id*` (vazio=topo), `position*` | Remove o elemento da posição actual e reinsere no destino — implementado como `remove_element()` + `insert_element()` sequenciais sobre a mesma árvore em memória, um único save no fim. | `check_edit_permission` | não-readonly, não-destructive, idempotente |
|
||||
| `remove-element` | `post_id*`, `element_id*` | Remove o elemento e **todos os filhos** da árvore. | `check_edit_permission` | não-readonly, **destructive**, idempotente |
|
||||
| `duplicate-element` | `post_id*`, `element_id*` | Clona profundamente o elemento (`reassign_element_ids()` — novos IDs em toda a subárvore, incluindo remapeamento de classes de estilo locais v4 se aplicável) e insere logo a seguir ao original. | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
|
||||
|
||||
### Gotchas de design documentados no código (Layout)
|
||||
|
||||
- **`is_container_type()` inclui os tipos atómicos** (`container`, `e-flexbox`, `e-div-block`) —
|
||||
não é só legado; `update-container`/`reorder-elements` reconhecem containers v4 também
|
||||
(comentário no código refere issues #104/#72 como o mesmo problema raiz).
|
||||
- **`add-container` recusa-se a criar um `container` legado se a experiência "Flexbox
|
||||
Container" do Elementor estiver desligada** (`EMCP_Tools_Atomic_Props::is_container_supported()`).
|
||||
Antes desta guarda (issue #111), o elemento era gravado com sucesso mas o Elementor
|
||||
simplesmente **não o renderiza** em runtime — página fica vazia sem qualquer erro visível ao
|
||||
agente. Este é o tipo de falha silenciosa mais perigosa do plugin: a tool "funciona" (devolve
|
||||
`success:true`) mas o resultado visual é nada. A mesma guarda está em `build-page` (ver §10).
|
||||
- **`full_bleed` preset (#83):** em páginas com template Canvas, os defaults "boxed" do
|
||||
Elementor deixam faixas brancas nas margens de secções full-width (headers/footers). O preset
|
||||
resolve isto de forma reutilizável em vez de o agente ter de descobrir os 6 campos certos por
|
||||
tentativa e erro.
|
||||
|
||||
---
|
||||
|
||||
## 3. `EMCP_Tools_Widget_Abilities` — `includes/abilities/class-widget-abilities.php`
|
||||
|
||||
**Condição de registo:** dentro de `if ( $elementor_active )`. `add-pro-widget` só regista se
|
||||
`defined('ELEMENTOR_PRO_VERSION')` (gate interno na própria classe, não no registrar). 3 tools.
|
||||
Construtor recebe `$data`, `$factory`, `$schema_generator` (`EMCP_Tools_Schema_Generator`,
|
||||
usado por `get-widget-schema` noutra classe P0, não aqui) e `$validator`
|
||||
(`EMCP_Tools_Settings_Validator`, usado para validar settings contra o schema do widget alvo).
|
||||
|
||||
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|
||||
|---|---|---|---|---|
|
||||
| `add-free-widget` | `post_id*`, `parent_id*`, `widget_type*`, `position`, `settings` | Valida tier via `EMCP_Tools_Widget_Catalog::is_pro($widget_type)` — **rejeita** (`wrong_tier`) se o tipo pedido for Pro/Woo. Faz merge dos `defaults` do catálogo (`entry['defaults']`) por baixo do `settings` do chamador (chamador sempre ganha). Delega ao motor comum `execute_add_widget()`. | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
|
||||
| `add-pro-widget` | idem `add-free-widget` | Espelho exacto, mas com o tier invertido — rejeita widgets free (aponta para `add-free-widget`). Só registada quando Elementor Pro está activo (gate natural: não faz sentido oferecer a tool se não há widgets Pro para colocar). | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
|
||||
| `update-widget` | `post_id*`, `element_id*`, `settings*` | Universal para instâncias de widget já colocadas: encontra o elemento, valida `elType==='widget'` (erro `not_a_widget` caso contrário), faz merge parcial de `settings`. | `check_edit_permission` | não-readonly, não-destructive, idempotente |
|
||||
|
||||
### Motor comum: `execute_add_widget()` (privado, partilhado pelas duas tools de inserção)
|
||||
|
||||
Passos: (1) valida que `widget_type` existe de facto no registo Elementor
|
||||
(`Plugin::$instance->widgets_manager->get_widget_types($widget_type)` — erro
|
||||
`invalid_widget_type` se não), (2) se `settings` não vazio, chama
|
||||
`EMCP_Tools_Settings_Validator::validate($widget_type, $settings)` (mesmo validador de
|
||||
`includes/validators/`, mas para controls de widget — distinto do `Element_Validator` que
|
||||
valida a *forma* estrutural do elemento, ver §Elementor_Data), (3) `factory->create_widget()`,
|
||||
(4) `data->insert_element()`, (5) `data->save_page_data()`.
|
||||
|
||||
**Achado de design:** o catálogo (`EMCP_Tools_Widget_Catalog`, não lido nesta tarefa — pertence
|
||||
provavelmente ao doc 02 ou doc 10) é a fonte de verdade de tier E de defaults por widget — as
|
||||
duas tools de inserção são finas camadas de gate+merge sobre um motor único; **não há lógica de
|
||||
posicionamento/inserção duplicada entre free e pro**.
|
||||
|
||||
---
|
||||
|
||||
## 4. `EMCP_Tools_Template_Abilities` — `includes/abilities/class-template-abilities.php`
|
||||
|
||||
**Condição de registo:** dentro de `if ( $elementor_active )`. `save-as-template` e
|
||||
`apply-template` sempre registadas; as outras 6 só se `defined('ELEMENTOR_PRO_VERSION')`
|
||||
(gate interno, `get_ability_names()` e `register()` espelham a mesma condição). 8 tools no
|
||||
total (2 free + 6 Pro).
|
||||
|
||||
| Tool | Tier | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|
||||
|---|---|---|---|---|---|
|
||||
| `save-as-template` | free | `post_id*`, `element_id` (omitir=página inteira), `title*`, `template_type` (`page`\|`section`\|`container`, default page) | Cria um post `elementor_library` com `_elementor_template_type`, define a taxonomia `elementor_library_type`, grava os elementos (página inteira ou só o elemento indicado) como `_elementor_data` desse novo post-template. | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
|
||||
| `apply-template` | free | `post_id*`, `template_id*`, `parent_id`, `position` | Lê o template, `reassign_ids()`, insere na página alvo (dentro de `parent_id` ou ao nível de topo). Devolve `elements_added` (contagem via `count_elements()`). | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
|
||||
| `create-elementor-theme-template` | **Pro** | `title*`, `template_type*` (enum: header/footer/single/single-post/single-page/archive/search-results/error-404/loop-item) | Cria post `elementor_library` do tipo indicado, inicializa com `_elementor_data=[]`. | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
|
||||
| `set-elementor-template-conditions` | **Pro** | `post_id*`, `conditions*` (array de arrays de partes, ex. `["include","singular","post"]`) | Ver "Gotcha crítico #38" abaixo — usa `save_elementor_conditions()`. | `check_edit_permission` | não-readonly, não-destructive, idempotente |
|
||||
| `list-dynamic-tags` | **Pro** | `group` (filtro opcional) | Enumera `Plugin::instance()->dynamic_tags->get_tags()`, filtra por grupo se indicado, devolve `{name, title, group, categories}` por tag. | `check_edit_permission` | **readonly**, idempotente |
|
||||
| `set-dynamic-tag` | **Pro** | `post_id*`, `element_id*`, `setting_key*`, `tag_name*`, `tag_settings` | Constrói o valor `[elementor-tag id="…" name="…" settings="…"]` (formato interno do Elementor, `settings` urlencoded como JSON) e escreve-o em `element.settings.__dynamic__[setting_key]`, tornando essa definição dinâmica. | `check_edit_permission` | não-readonly, não-destructive, idempotente |
|
||||
| `create-popup` | **Pro** | `title*` | Cria post `elementor_library` tipo `popup`. | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
|
||||
| `set-popup-settings` | **Pro** | `post_id*`, `triggers`, `conditions`, `timing` | Grava `_elementor_popup_triggers`/`_elementor_popup_timing` em post meta directamente; `conditions` reutiliza o MESMO `save_elementor_conditions()` seguro que o template (não é um caminho separado). | `check_edit_permission` | não-readonly, não-destructive, idempotente |
|
||||
|
||||
### Gotcha crítico documentado (#38) — `save_elementor_conditions()`
|
||||
|
||||
Método privado partilhado por `set-elementor-template-conditions` e `set-popup-settings`.
|
||||
**A abordagem anterior** (`update_post_meta()` + `delete_option()` na cache global
|
||||
`elementor_pro_theme_builder_conditions`) **invalidava a localização de TODOS os templates**
|
||||
sem os reconstruir — definir condições num template partia silenciosamente headers/footers não
|
||||
relacionados até um rebuild completo. A abordagem correcta passa pelo **conditions manager** do
|
||||
próprio Elementor Pro (`ThemeBuilder::get_conditions_manager()->save_conditions()`), que
|
||||
regenera a cache correctamente; só cai para escrita directa de meta (sem tocar na cache global)
|
||||
se o gestor Pro não estiver disponível. **Blueprint:** nunca fazer bypass da API de alto nível
|
||||
de um plugin de terceiros para "poupar uma chamada" quando essa API mantém uma cache
|
||||
side-effectful — o preço é corromper estado partilhado fora do escopo da própria operação.
|
||||
|
||||
---
|
||||
|
||||
## 5. `EMCP_Tools_Global_Abilities` — `includes/abilities/class-global-abilities.php`
|
||||
|
||||
**Condição de registo:** dentro de `if ( $elementor_active )`, sempre. Construtor só recebe
|
||||
`$data` (não usa `$factory`). 2 tools — actuam sobre o **kit activo** do Elementor
|
||||
(`Plugin::$instance->kits_manager->get_active_kit()`), que é o post que guarda paleta global de
|
||||
cores e tipografia site-wide.
|
||||
|
||||
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|
||||
|---|---|---|---|---|
|
||||
| `update-global-colors` | `colors*` (array de `{_id*, title*, color*}` hex) | Lê `kit_settings['custom_colors']`, faz merge por `_id` (actualiza existentes, acrescenta novos), `kit->update_settings(['custom_colors'=>...])`. Regista snapshot no change-ledger ANTES de escrever (`snapshot_kit_settings()`/`record_kit_change()` — captura `_elementor_page_settings` do kit para rollback). | `check_manage_permission`: `manage_options` | não-readonly, não-destructive, idempotente |
|
||||
| `update-global-typography` | `typography*` (array de `{_id*, title*, typography_font_family, typography_font_size, typography_font_weight, typography_line_height, typography_letter_spacing}`) | Mesmo padrão merge-por-`_id` sobre `kit_settings['custom_typography']`, mas com **allowlist explícita de chaves** (`allowed_keys`) — qualquer campo fora dessa lista é descartado silenciosamente. Força sempre `typography_typography='custom'` (activa o override; sem isto o Elementor ignora os campos custom). | `check_manage_permission` | não-readonly, não-destructive, idempotente |
|
||||
|
||||
**Achado de design — a única classe do doc com change-ledger integrado directamente no fluxo de
|
||||
escrita** (não via o hook central de `save_page_data()`, porque estas duas tools não passam por
|
||||
`_elementor_data` nenhuma — escrevem `_elementor_page_settings` do post do kit). `snapshot_kit_settings()`
|
||||
+ `record_kit_change()` chamam `EMCP_Tools_Change_Recorder::record_meta()` explicitamente antes
|
||||
do `update_settings()`, porque de outra forma uma mudança de cor/tipografia global — que afecta
|
||||
**todas as páginas do site simultaneamente** — não teria rollback nenhum via o ledger genérico
|
||||
(esse só cobre `_elementor_data` de um post individual, ver `class-elementor-data.php`).
|
||||
**Blueprint:** qualquer mutação "global" que não passe pelo caminho de escrita comum de página
|
||||
precisa do seu PRÓPRIO ponto de integração com o change-ledger — não é automático.
|
||||
|
||||
---
|
||||
|
||||
## 6. `EMCP_Tools_Global_Classes_Abilities` — `includes/abilities/class-global-classes-abilities.php`
|
||||
|
||||
**Condição de registo:** classe auto-gated — `is_available()` verifica
|
||||
`class_exists('\Elementor\Modules\GlobalClasses\Global_Classes_Repository')` (Elementor 4.0+).
|
||||
O registrar só instancia se `class_exists('EMCP_Tools_Global_Classes_Abilities')` E chama
|
||||
sempre `register()`, que internamente re-verifica `is_available()`. 1 tool, **read-only**.
|
||||
Resolve a peça mais opaca do sistema de design do Elementor 4.0+: elementos referenciam classes
|
||||
CSS globais só pelo ID opaco `g-xxxxxxx`; sem esta tool, um agente que lê um elemento vê o ID
|
||||
mas não sabe o que ele estiliza.
|
||||
|
||||
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|
||||
|---|---|---|---|---|
|
||||
| `list-global-classes` | `class_ids` (array opcional; omitir = todas) | `Global_Classes_Repository::make()->all()` → normaliza `Collection`/array, para cada item resolve `{id, label, css}` onde `css` é `flatten_variants()` — um mapa `breakpoint[:state] → {prop:valor}` com os `$$type`-wrapped props desembrulhados via `EMCP_Tools_Atomic_Props::unwrap()`. | `check_read_permission`: `edit_posts` | **readonly**, idempotente |
|
||||
|
||||
### Gotcha documentado (#57) — resolução defensiva por item
|
||||
|
||||
Cada item é resolvido dentro do seu próprio `try/catch`. **Antes desta guarda**, uma única
|
||||
classe malformada fazia a enumeração inteira (`resolve-all`, sem `class_ids`) falhar por
|
||||
completo, enquanto pedidos com `class_ids` explícitos (que saltam a entrada má) continuavam a
|
||||
funcionar — inconsistência confusa de diagnosticar ("porque é que só falha quando não passo
|
||||
IDs?"). A correcção devolve a classe problemática mesmo assim, com `css:[]` e um campo `error`
|
||||
explicativo, em vez de a fazer desaparecer da lista ou abortar tudo. **Blueprint:** ao
|
||||
enumerar uma colecção de N itens onde cada um pode ter forma inesperada, isolar cada resolução —
|
||||
nunca deixar um item mau abortar os outros N-1.
|
||||
|
||||
---
|
||||
|
||||
## 7. `EMCP_Tools_Global_Classes_Write_Abilities` — `includes/abilities/class-global-classes-write-abilities.php`
|
||||
|
||||
**Condição de registo:** mesmo padrão auto-gated (`is_available()` delega para a classe de
|
||||
leitura se existir, senão verifica a mesma constante `REPOSITORY` directamente). 4 tools —
|
||||
**todas em `emcp_tools_disabled_tools` por omissão** (mutação de CSS partilhado entre todas as
|
||||
páginas do site é tratada como categoria de alto risco, ver `skill://emcp-tools` §2.9/§5).
|
||||
Permissão de escrita: `elementor_global_classes_update_class` (capability própria do Elementor,
|
||||
tipicamente só admin) OU `manage_options`.
|
||||
|
||||
| Tool | Input (resumo) | O que faz | Destructive |
|
||||
|---|---|---|---|
|
||||
| `create-global-class` | `label*`, `styles` (mapa amigável), `props` (escape-hatch raw `$$type`), `breakpoint` (enum de 7 valores, default desktop), `state` (opcional: hover/focus/…) | Lê o estado actual (`items`, `order`) via `read_state()`, gera um novo ID (`mint_id()` — `g-` + 4 bytes hex aleatórios, sem colisão), constrói o objecto `{id, type:class, label, variants:[{meta, props}]}` combinando `styles` (traduzido via `EMCP_Tools_Atomic_Styles::build_common_props()`+`build_flex_props()`) com `props` raw por cima, escreve com `write_state()`. | não |
|
||||
| `update-global-class` | `id*`, `label`, `styles`, `props`, `breakpoint`, `state`, `replace_variant` (bool) | Localiza a variante pelo par `(breakpoint, state)` (`find_variant_index()`); se não existir, acrescenta uma nova variante; se existir e `replace_variant=false` (default), faz merge dos props na variante existente; se `true`, substitui-a inteira. | não |
|
||||
| `delete-global-class` | `id*`, `confirm*` (deve ser `true`) | Remove do mapa `items` e da lista `order`; **exige `confirm:true`** explicitamente — é a única das 4 a ter esse requisito extra, porque apaga a classe de TODOS os elementos que a usam. | **sim** |
|
||||
| `reorder-global-classes` | `order*` (array de IDs `g-`) | A ordem do Class Manager É a ordem de saída CSS — decide qual classe ganha quando duas se aplicam ao mesmo elemento com a mesma especificidade. IDs omitidos em `order` são acrescentados no fim, na ordem actual — **nenhuma classe pode desaparecer** por um reorder parcial (a "baseline order" é a união de `current_order` + `array_keys(items)`, nunca só o que o chamador mandou). | não |
|
||||
|
||||
### Como as escritas persistem — o padrão `read_state()`/`write_state()`
|
||||
|
||||
Todas as 4 tools passam pelo repositório oficial do Elementor
|
||||
(`Global_Classes_Repository::make()`), nunca por meta directa: lê o mapa completo `id => item`
|
||||
+ `order[]`, muta em memória, chama `put($items, $order)` — **o Elementor calcula o diff
|
||||
add/modify/delete internamente** e trata relações + limpeza de uso. `write_state()` faz
|
||||
best-effort de espelhar também para o contexto de preview (`set_preview(true)->put(...)`) — se
|
||||
esse segundo write falhar, é tolerado silenciosamente (só logado com `WP_DEBUG`) porque **o
|
||||
write de frontend é a fonte de verdade**; o preview só afecta o que o editor mostra até
|
||||
recarregar.
|
||||
|
||||
**Achado de design — reutilização directa dos tijolos atómicos v4:** `build_variant_props()`
|
||||
chama `EMCP_Tools_Atomic_Styles::build_common_props()`/`build_flex_props()` — as MESMAS classes
|
||||
de suporte que o sistema de widgets atómicos (doc 02) usa para construir estilos locais por
|
||||
elemento. Isto significa que "escrever uma Global Class" e "aplicar um estilo local a um
|
||||
elemento atómico" partilham o mesmo motor de tradução `styles amigável → props $$type-wrapped`
|
||||
— não há dois formatos de estilo diferentes no plugin, só dois destinos de armazenamento
|
||||
(classe global partilhada vs. classe local de um elemento).
|
||||
|
||||
---
|
||||
|
||||
## 8. `EMCP_Tools_Custom_Code_Abilities` — `includes/abilities/class-custom-code-abilities.php`
|
||||
|
||||
**Condição de registo:** dentro de `if ( $elementor_active )`. `add-custom-js` sempre regista
|
||||
(funciona com Elementor free, via widget HTML); as outras 3 só se `defined('ELEMENTOR_PRO_VERSION')`.
|
||||
4 tools no total (1 free + 3 Pro). Injecção de código executável — a classe com o perfil de
|
||||
risco mais alto deste doc, com o maior número de comentários de segurança no código-fonte.
|
||||
|
||||
| Tool | Tier | Input (resumo) | O que faz | `permission_callback` | Destructive |
|
||||
|---|---|---|---|---|---|
|
||||
| `add-custom-css` | **Pro** | `post_id*`, `element_id` (omitir=nível de página), `css*`, `replace` (bool) | CSS por elemento usa o placeholder `selector` como wrapper (substituído pelo Elementor no seu gerador de CSS); grava em `settings.custom_css` do elemento ou em `page_settings.custom_css`. Por omissão faz *append*; `replace=true` sobrescreve. Sanitização: remove tags PHP e `<script>`, e **neutraliza `</style>` em loop até fixpoint** (ver F-004 abaixo). | `check_edit_permission` | não |
|
||||
| `add-custom-js` | free | `post_id*`, `parent_id*`, `js*`, `position`, `wrap_dom_ready` (bool) | Insere um **widget HTML** contendo `<script>{js}</script>` na árvore da página (não é injecção site-wide, é conteúdo normal da página). Remove qualquer `<script>`/`</script>` que o chamador já tenha incluído (evita duplo-wrap); opcionalmente envolve em `DOMContentLoaded`. | `check_js_permission`: `edit_posts`+per-post **E** `unfiltered_html` — a única tool do grupo a exigir `unfiltered_html` além da capability de edição normal, porque injecta um `<script>` executável que o WordPress tiraria a um utilizador sem essa capability (ex. não-super-admin em multisite) | não |
|
||||
| `add-code-snippet` | **Pro** | `title*`, `code*`, `location` (`head`\|`body_start`\|`body_end`, default head), `priority` (1-10, clamp), `status` (`publish`\|`draft`), `ensure_jquery` (bool) | Cria um post CPT `elementor_snippet` com meta `_elementor_location`/`_elementor_priority`/`_elementor_code`/`_elementor_template_type=code_snippet` — **injecção site-wide**, em TODAS as páginas, ao contrário de `add-custom-js` (só naquela página). | `check_snippet_permission`: `manage_options` **E** `unfiltered_html` | não |
|
||||
| `list-code-snippets` | **Pro** | `location` (filtro), `status` (default `any`) | Lista posts `elementor_snippet` (até 100), devolve `{id, title, location, priority, status, code, edit_url}` por snippet. | `check_manage_permission`: `manage_options` | readonly |
|
||||
|
||||
### Gotchas de segurança documentados no código (Custom Code)
|
||||
|
||||
- **F-004 — bypass de `</style>` neutralizado em loop até fixpoint:** o CSS de `add-custom-css`
|
||||
é emitido dentro de um bloco `<style>`, que o parser HTML trata como texto bruto — a ÚNICA
|
||||
forma de escapar para HTML vivo (vector XSS, ex. `</style><img onerror=...>`) é a tag literal
|
||||
`</style>`; `<`, `>` isolados ou até `<img>` sozinhos são inertes sem ela. O código remove
|
||||
`</\s*style` **num `while` até `$previous === $css`**, especificamente para impedir que
|
||||
remover UMA ocorrência reconstrua outra por concatenação adjacente. Importante: preserva TODA
|
||||
a CSS válida — combinadores `>`/`~`/`+`, media queries com `<`/`>`, strings de conteúdo — só a
|
||||
sequência exacta `</style` desaparece.
|
||||
- **F-008 — regex de handlers `on*=` precisa da flag `/s` (DOTALL):** em `sanitize_svg_content()`
|
||||
(classe SVG, não esta, mas o padrão de regex é idêntico e vale a pena registar aqui porque
|
||||
`add-custom-css` faz sanitização de string semelhante) — sem `/s`, um handler cujo VALOR contém
|
||||
uma quebra de linha (`onclick="alert(1)\n"`) escapa ao match porque `.` por omissão não cruza
|
||||
linhas.
|
||||
- **Distinção free vs Pro não é arbitrária:** `add-custom-js` (free) é sempre **por-página** (um
|
||||
widget HTML normal, mesma superfície de risco que qualquer conteúdo de página); os 3 Pro
|
||||
operam **site-wide** — `add-custom-css` a nível de página TAMBÉM é possível mas
|
||||
`add-code-snippet` injecta sempre em todas as páginas. É essa amplitude, não a linguagem em
|
||||
si, que justifica o gate `manage_options` (site-wide) vs `edit_posts` (por-página).
|
||||
|
||||
---
|
||||
|
||||
## 9. `EMCP_Tools_Svg_Icon_Abilities` — `includes/abilities/class-svg-icon-abilities.php`
|
||||
|
||||
**Condição de registo:** dentro de `if ( $elementor_active )`, sempre — mas na prática a tool
|
||||
não depende de nada específico do Elementor além do formato de saída (o objecto ícone). 1 tool.
|
||||
|
||||
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|
||||
|---|---|---|---|---|
|
||||
| `upload-svg-icon` | `svg_url` OU `svg_content` (mutuamente exclusivos), `title` | Faz upload/sideload de um SVG para a Media Library e devolve o objecto de ícone Elementor pronto a usar: `{value:{id,url}, library:'svg'}` — directamente atribuível a `selected_icon` em qualquer widget icon/icon-box/button. | `check_upload_permission`: `upload_files` | não-readonly, não-destructive, não-idempotente |
|
||||
|
||||
### Pipeline de segurança (fail-closed em 3 camadas)
|
||||
|
||||
1. **Download (`svg_url`):** via `EMCP_Tools_Url_Guard::safe_download()` — guarda SSRF que
|
||||
bloqueia hosts privados/reservados/loopback e **revalida cada hop de redirect** (não é lido
|
||||
nesta tarefa, mas o nome da classe/comportamento fica documentado como dependência crítica).
|
||||
2. **Bypass temporário do filtro de MIME do WordPress:** dois filtros temporários
|
||||
(`upload_mimes`, `wp_check_filetype_and_ext`) permitem o `.svg` só durante a chamada e são
|
||||
removidos logo a seguir — não altera a política global de uploads do site.
|
||||
3. **Sanitização fail-closed, dupla camada:**
|
||||
- Verificação superficial: rejeita se contiver `<script` (regra própria, antes de chamar o
|
||||
sanitizador).
|
||||
- Sanitizador real: `\Elementor\Core\Utils\Svg\Svg_Sanitizer` (classe do PRÓPRIO Elementor,
|
||||
reaproveitada — não é uma dependência própria do EMCP Tools). **Se a classe do sanitizador
|
||||
não existir, a tool recusa o upload por completo** (`no_svg_sanitizer`) em vez de aceitar
|
||||
markup minimamente verificado — fail-closed genuíno, não um "melhor esforço".
|
||||
- Para `svg_content`: além do sanitizador, regex adicionais removem handlers `on*=` (com
|
||||
`/s` — ver F-008 acima) e URLs `javascript:`.
|
||||
|
||||
**Blueprint:** este é o padrão de referência para QUALQUER tool de upload de conteúdo
|
||||
potencialmente executável (SVG pode conter script) — nunca confiar só na extensão de ficheiro
|
||||
nem só num sanitizador; usar o MELHOR sanitizador disponível (aqui, reaproveitar o do Elementor
|
||||
em vez de reescrever um) e recusar em vez de degradar quando ele não está presente.
|
||||
|
||||
---
|
||||
|
||||
## 10. `EMCP_Tools_Composite_Abilities` — `includes/abilities/class-composite-abilities.php`
|
||||
|
||||
**Condição de registo:** dentro de `if ( $elementor_active )`, sempre. 1 tool — mas é a mais
|
||||
sofisticada do doc: constrói uma página inteira a partir de uma especificação declarativa
|
||||
aninhada, numa única chamada MCP.
|
||||
|
||||
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|
||||
|---|---|---|---|---|
|
||||
| `build-page` | `title*`, `status` (default draft), `post_type` (default page), `page_settings`, `dry_run` (bool), `structure*` (árvore declarativa: `{type, widget_type?, settings?, children?}[]`) | Constrói recursivamente a árvore Elementor a partir de `structure` (em memória, sem write ainda), cria o post e grava só se `dry_run` não estiver activo. | `check_create_permission`: `publish_pages` \|\| `edit_pages` | não-readonly, não-destructive, não-idempotente |
|
||||
|
||||
### Passos de execução (`execute_build_page`)
|
||||
|
||||
1. **Guarda de suporte de container** (idêntica à de `add-container`, ver §2) — recusa
|
||||
antecipadamente se a experiência Flexbox Container estiver desligada, porque `build-page`
|
||||
emite `container`s legados que renderizariam vazios (#111).
|
||||
2. **`build_elements()` recursivo** — percorre `structure`, normaliza cada nó
|
||||
(`normalize_node()`, ver abaixo), constrói `container`s ou widgets, acumula
|
||||
`$this->elements_created` e `$this->warnings`.
|
||||
3. Se `elements_created > 150` (`SOFT_ELEMENT_LIMIT`), acrescenta um aviso sobre risco de
|
||||
timeout num conector MCP remoto — sugere `dry_run` primeiro ou dividir em várias chamadas.
|
||||
4. **`dry_run=true`:** devolve `{dry_run:true, would_create:N, warnings:[...]}` **sem tocar na
|
||||
base de dados** — nenhum post é criado.
|
||||
5. Caso contrário: `wp_insert_post()`, `save_page_data()`, `save_page_settings()` (se
|
||||
fornecido), devolve `post_id`/`edit_url`/`preview_url`/`elements_created`/`warnings`.
|
||||
|
||||
### `normalize_node()` — coerção tolerante de shorthand de modelos fracos
|
||||
|
||||
Modelos de IA mais fracos escrevem rotineiramente `{"type":"heading", ...}` em vez da forma
|
||||
canónica `{"type":"widget","widget_type":"heading"}`, ou dão a um container um `type` diferente
|
||||
de `"container"` mas ainda com `children`. Em vez de descartar silenciosamente estes nós (o que
|
||||
faria o pedido "ter sucesso" com colunas vazias no resultado — o pior tipo de falha, porque
|
||||
parece funcionar), `normalize_node()`:
|
||||
- Se o nó tem `children` não-vazio → tratado como container, independentemente do `type`
|
||||
declarado; se o `type` original não era `"container"`, regista um warning.
|
||||
- Se não tem `children` e tem `type` não-vazio → interpretado como **shorthand de widget**: o
|
||||
próprio `type` torna-se `widget_type` (ex. `"heading"` → widget heading); warning explica a
|
||||
forma preferida.
|
||||
- Devolve sempre o nó com `type` canónico (`container`/`widget`), nunca lança erro — a
|
||||
filosofia é "aceitar o que o modelo quis dizer, mas dizer exactamente o que foi assumido".
|
||||
|
||||
### Layout automático em containers `flex_direction=row`
|
||||
|
||||
Quando um container pai tem `flex_direction=row` (ou `row-reverse`) e mais de 1 filho:
|
||||
- Containers filhos SEM largura explícita (`width`/`_flex_size`/`_flex_grow` já definidos)
|
||||
recebem `content_width=full` + `width={size: 100/N, unit:'%'}` automaticamente — replica o
|
||||
padrão nativo de colunas do Elementor sem o agente ter de calcular percentagens.
|
||||
- **Widgets colocados directamente como filho de um row** (sem container intermédio) são
|
||||
**automaticamente envolvidos** num container-coluna com a mesma largura calculada — porque o
|
||||
modelo flex do Elementor exige um container como flex-item; um widget "nu" não tem
|
||||
flex-basis e simplesmente esticaria para preencher a row em vez de formar uma coluna própria.
|
||||
Isto acrescenta um elemento extra à árvore (contabilizado em `elements_created`) que o
|
||||
chamador não pediu explicitamente, mas sem o qual o layout pedido (colunas lado-a-lado) nem
|
||||
sequer seria possível.
|
||||
- **Explicitamente PROIBIDO no schema** (bloco `description` da tool): nunca definir `flex_wrap`
|
||||
ou `_flex_size` manualmente — a tool já trata disto e sobreposições manuais causam overflow de
|
||||
layout.
|
||||
|
||||
### `build_widget()` — ponte para widgets atómicos v4 dentro de `build-page`
|
||||
|
||||
Se `EMCP_Tools_Atomic_Widget_Map::is_atomic($widget_type)` for verdadeiro, `build-page` **não**
|
||||
usa `factory->create_widget()` legado — usa `factory->create_atomic_widget()` com os settings
|
||||
mapeados por `EMCP_Tools_Atomic_Widget_Map::settings()` (o mesmo mapeamento que as tools
|
||||
`add-atomic-*` individuais usam, doc 02), e aplica os parâmetros de estilo restantes como uma
|
||||
classe local via `EMCP_Tools_Atomic_Styles::create_local_class()`. Isto significa que
|
||||
**`build-page` é atomic-aware por composição**, não por duplicação — reaproveita inteiramente o
|
||||
motor do doc 02 em vez de ter a sua própria lógica de tradução de props atómicas.
|
||||
|
||||
**Blueprint:** `build-page` é o exemplo mais claro no plugin de "tool de alto nível como
|
||||
orquestrador fino sobre primitivas de baixo nível" — não introduz nenhuma capacidade de
|
||||
persistência nova, só composição declarativa + tolerância a input ambíguo em cima de
|
||||
`Element_Factory`+`Data`(+`Atomic_*` quando aplicável). Uma réplica deveria construir esta tool
|
||||
POR ÚLTIMO, depois de todas as primitivas (container/widget/atomic) já funcionarem
|
||||
individualmente.
|
||||
|
||||
---
|
||||
|
||||
## Serviços de suporte
|
||||
|
||||
### `EMCP_Tools_Element_Factory` — `includes/class-element-factory.php`
|
||||
|
||||
Fábrica pura (sem I/O, sem WordPress DB) que constrói arrays PHP no formato exacto que o
|
||||
Elementor espera para cada tipo de elemento. Métodos: `create_container()`, `create_widget()`,
|
||||
`create_section()`/`create_column()` (legado pré-Container, usado só residualmente — nenhuma
|
||||
tool destas 10 classes os invoca directamente, mantidos por compatibilidade), e os 3 atómicos
|
||||
`create_atomic_widget()`/`create_flexbox()`/`create_div_block()` (Elementor 4.0+, cobertos em
|
||||
detalhe no doc 02 mas usados aqui indirectamente por `build-page`).
|
||||
|
||||
**Duas normalizações estáticas reutilizadas em toda a base de código** (chamadas não só pela
|
||||
factory mas também por `EMCP_Tools_Data::update_element_settings()`):
|
||||
|
||||
- **`normalize_container_settings()`** — remapeia os atalhos sem prefixo `justify_content` /
|
||||
`align_items` / `align_content` para as chaves prefixadas `flex_justify_content` /
|
||||
`flex_align_items` / `flex_align_content` que o schema de container do Elementor **realmente
|
||||
lê**. **Gotcha crítico (#32):** sem este remap, os valores eram persistidos sob nomes que o
|
||||
gerador de CSS do Elementor nunca consulta — as custom properties CSS (`--justify-content`,
|
||||
`--align-items`) nunca são emitidas e o container renderiza com alinhamento default no
|
||||
frontend, apesar dos dados estarem "correctos" na base de dados. Chaves prefixadas fornecidas
|
||||
pelo chamador sempre ganham sobre o atalho, se ambas aparecerem no mesmo payload.
|
||||
- **`normalize_background_settings()`** — corrige 3 formas erradas-mas-intuitivas de background
|
||||
que modelos fracos emitem: (1) um grupo aninhado `background: {background_image, size, ...}`
|
||||
é achatado para chaves `background_*` de topo (o Elementor não tem control de grupo
|
||||
`background`, um objecto aninhado é simplesmente ignorado); (2) `background_image` dado como
|
||||
array de objectos `[{id,url}]` (o modelo espelha a forma de um media-repeater) é desembrulhado
|
||||
para o objecto único `{id,url}` esperado; (3) quando existe imagem OU cor mas falta o
|
||||
activador `background_background`, injecta `classic` automaticamente — sem o activador o
|
||||
Elementor nunca renderiza background nenhum. Idempotente e não-destrutivo: chaves planas já
|
||||
fornecidas pelo chamador sempre ganham sobre o que é elevado do grupo aninhado.
|
||||
|
||||
Container `create_container()` também aplica um default de UX: **auto-centra
|
||||
`flex_align_items='center'`** em containers coluna não-grid quando o chamador não especificou
|
||||
alinhamento — só linhas (`row`) ficam com o comportamento default do Elementor.
|
||||
|
||||
### `EMCP_Tools_Data` — `includes/class-elementor-data.php`
|
||||
|
||||
**A camada de leitura/escrita real do `_elementor_data`** — usada por praticamente todas as 10
|
||||
classes deste doc (excepto Global Classes, que usa o repositório próprio do Elementor
|
||||
directamente, e Global_Abilities, que usa o kit manager). ~700 linhas; o ficheiro mais denso em
|
||||
comentários de bug-fix real de todo o doc. Métodos-chave:
|
||||
|
||||
- **`get_document()`** — obtém o `\Elementor\Core\Base\Document` para um post via
|
||||
`Plugin::$instance->documents->get($post_id)`. Guardado por `elementor_documents_ready()`:
|
||||
o gestor de documentos do Elementor só existe depois do seu próprio hook `init` correr; durante
|
||||
a ACTIVAÇÃO do Elementor (que insere o kit por omissão via `save_post`, o que dispara o
|
||||
indexador do EMCP Tools) essa dependência ainda não existe — sem a guarda seria um fatal
|
||||
error por null-deref (#105).
|
||||
- **`get_page_data()`** — tenta primeiro `$document->get_elements_data()`; se vazio, cai para
|
||||
leitura directa de `_elementor_data` (post meta bruto, `json_decode`). O fallback existe
|
||||
porque em contexto CLI/proxy (sem browser, sessão de editor) o API do documento por vezes
|
||||
devolve vazio mesmo com dados presentes na base de dados.
|
||||
- **`save_page_data()` — o método mais complexo de todo o ficheiro (~140 linhas).** Fluxo
|
||||
completo:
|
||||
1. **`EMCP_Tools_Atomic_Props::coerce_tree($data)`** — varre a árvore INTEIRA (não só o
|
||||
elemento a alterar) antes de gravar. **Gotcha #102:** uma versão anterior só coagia o
|
||||
elemento sendo escrito; como o Elementor 4.x valida a ÁRVORE COMPLETA no save, um único
|
||||
widget com um valor de prop bruto (não `$$type`-wrapped) noutro sítio da página bloqueava
|
||||
TODOS os saves futuros — incluindo o save destinado a reparar esse mesmo widget. Fazer a
|
||||
coerção ser sempre sobre a árvore inteira é um no-op para páginas saudáveis e uma rede de
|
||||
segurança universal para páginas com dados legados/corrompidos.
|
||||
2. **Preserva `_elementor_data` corrupto** antes de sobrescrever — se a meta actual for uma
|
||||
string não-vazia que não faz `json_decode` válido, é copiada para
|
||||
`_elementor_data_emcp_corrupt` antes do save prosseguir. Sem isto, `get_page_data()` trata
|
||||
"corrupto" como "vazio" e um save subsequente apagaria os dados originais para sempre.
|
||||
3. **`try { $document->save(...) } catch (\Throwable $e)`** — o Elementor 4.x atómico
|
||||
**lança excepção** (não devolve `false`) quando a validação de settings/estilos falha.
|
||||
`is_atomic_validation_rejection()` distingue uma rejeição de validação legítima (mensagem
|
||||
contém "validation failed" ou "invalid_value") de um erro fatal genuíno. **Gotcha #112:**
|
||||
um prop `{$$type:'dynamic'}` gravado pelo editor ao vivo pode referenciar uma dynamic tag
|
||||
que o registo atómico em contexto CLI/REST não consegue resolver — e como a validação é
|
||||
sobre a árvore inteira, isso bloquearia QUALQUER save da página, incluindo edições a
|
||||
elementos totalmente não relacionados. Uma rejeição de validação é tratada como "dados
|
||||
legítimos que este contexto não sabe verificar" e roteada para o fallback de meta directa
|
||||
em vez de reprovada.
|
||||
4. **Verificação pós-save (#98):** mesmo quando `$document->save()` devolve verdadeiro sem
|
||||
excepção, relê `_elementor_data` e confirma que os dados enviados realmente persistiram —
|
||||
em certos contextos 4.x/atómicos/REST o save pode devolver "sucesso" e ainda assim
|
||||
esvaziar `_elementor_data`. Se detectado, força o mesmo fallback de escrita directa em vez
|
||||
de reportar um sucesso fantasma ao chamador.
|
||||
5. **Fallback de meta directa:** `update_post_meta('_elementor_data', wp_slash(json_encode($data)))`
|
||||
+ garante `_elementor_edit_mode=builder` + `_elementor_version` + invalida cache CSS
|
||||
(`delete_post_meta('_elementor_css')` + apaga o ficheiro físico
|
||||
`uploads/elementor/css/post-{id}.css` se existir) + invalida a cache de elemento renderizado
|
||||
do Elementor 4.2 (`_elementor_element_cache`, ver hook `init()` abaixo).
|
||||
6. **Regista no change-ledger** — `EMCP_Tools_Change_Recorder::record_elementor()` (ou
|
||||
fallback directo a `EMCP_Tools_Change_Log::record()`), capturando o `_elementor_data`
|
||||
ANTERIOR completo para permitir rollback via `rollback-change` (doc 05).
|
||||
- **`init()` (hook estático global)** — regista em `added_post_meta`/`updated_post_meta`: sempre
|
||||
que `_elementor_data` é escrito, por QUALQUER caminho (as nossas tools, o editor, um import),
|
||||
apaga `_elementor_element_cache`. **Motivo (#111 revisitado):** o Elementor 4.2 introduziu uma
|
||||
cache de HTML renderizado nessa meta key; o próprio Elementor limpa-a em `Document::save()`,
|
||||
mas o fallback de meta directa do EMCP Tools bypassa isso. Numa instalação com object cache
|
||||
persistente (ex. WP Engine), uma entrada vazia/obsoleta sobrevivia a QUALQUER escrita de
|
||||
conteúdo subsequente — uma página criada via MCP (escrita enquanto os dados ainda eram `[]`,
|
||||
renderizada [caching vazio], depois preenchida) servia o render vazio em cache para sempre.
|
||||
Este hook restaura a invalidação universalmente para qualquer caminho de escrita.
|
||||
- **`insert_element()` / `remove_element()` / `reassign_ids()` / `reassign_element_ids()` /
|
||||
`count_elements()` / `find_element_by_id()`** — utilitários recursivos puros sobre a árvore em
|
||||
memória (todos operam por referência `&$data` onde relevante para evitar cópias de arrays
|
||||
grandes a cada nível de recursão). `reassign_element_ids()` também chama
|
||||
`EMCP_Tools_Atomic_Styles::remap_local_classes()` — **gotcha #97:** classes de estilo locais
|
||||
v4 (`e-<id>-<hash>`) pertencem a UM elemento; duplicar um elemento sem remapear as suas classes
|
||||
locais fazia o duplicado partilhar as classes do original — vazamento de estilo entre
|
||||
elementos e duplicação da "Origem de Estilo" no editor.
|
||||
- **`update_element_settings()` — o segundo método mais complexo.** Além do merge óbvio de
|
||||
`settings`, faz:
|
||||
- **Hoist de chaves-irmãs da raiz** (`styles`, `editor_settings`) — em elementos atómicos v4,
|
||||
o mapa `styles` local e `editor_settings` (rótulo Navigator) vivem na RAIZ do elemento, como
|
||||
irmãos de `settings`, não dentro dele. Um agente naturalmente aninha-os sob `settings`;
|
||||
`update_element_settings()` intercepta essas duas chaves ANTES do merge normal, remove-as do
|
||||
payload de settings, e faz `deep_merge()` para a raiz do elemento (**gotcha #72/#73** — sem
|
||||
isto, eram gravadas em `settings.styles`, uma chave morta que o Elementor nunca lê).
|
||||
- **Normalização condicional por tipo:** containers passam por
|
||||
`normalize_container_settings()`; qualquer outro elType passa só por
|
||||
`normalize_background_settings()` (mesma correcção de background, sem o remap de flex que
|
||||
só faz sentido em containers).
|
||||
- **`EMCP_Tools_Atomic_Props::coerce_settings()` no settings MERGED** (não só no incoming) para
|
||||
widgets — **gotcha #101:** um valor bruto (`'Hello'` em vez de
|
||||
`{$$type:'html-v3',value:'Hello'}`) em prop atómico não é simplesmente ignorado — "envenena"
|
||||
o elemento: o Elementor cai para o default do prop (renderiza texto placeholder) E todo o
|
||||
save subsequente da página lança "Settings validation failed", trancando a página fora tanto
|
||||
da API como do próprio editor visual. Correr a coerção sobre o resultado do merge aceita
|
||||
valores simples que um agente naturalmente envia E repara qualquer coisa que uma versão
|
||||
anterior já tenha gravado incorrectamente.
|
||||
- **`sync_local_class_refs()` quando `styles` foi tocado — gotcha #92:** uma classe de estilo
|
||||
local só renderiza se `settings.classes` (o prop `$$type:'classes'` que lista IDs aplicados)
|
||||
referenciar o seu ID. Um agente que escreve um mapa `styles` mas esquece de acrescentar o ID
|
||||
a `classes` obtém um no-op silencioso — o estilo persiste na base de dados mas nunca se
|
||||
aplica visualmente. Este método varre `item['styles']` (só entradas `type==='class'`) e
|
||||
garante que todos os IDs aparecem em `settings.classes.value`, idempotente.
|
||||
- **`deep_merge()`** — merge recursivo próprio: mapas associativos fazem merge chave-a-chave;
|
||||
listas (arrays numéricos sequenciais, ex. um array `variants`) e escalares são **substituídos
|
||||
por inteiro** pelo valor incoming. Permite que um update parcial de `styles`/`editor_settings`
|
||||
toque só uma classe/chave sem apagar as irmãs, mantendo ao mesmo tempo a substituição total
|
||||
quando o chamador manda de facto uma lista nova completa.
|
||||
|
||||
### `EMCP_Tools_Element_Validator` — `includes/validators/class-element-validator.php`
|
||||
|
||||
Classe pequena e propositadamente simples (~55 linhas) — valida só a **forma estrutural** de um
|
||||
elemento isolado antes de ser gravado: `id` presente, `elType` presente e num allowlist fixo
|
||||
(`container`, `widget`, `section`, `column`, mais os tipos atómicos `e-div-block`, `e-flexbox`,
|
||||
mais os tipos de estrutura de formulário atómico `e-tabs*`/`e-form*`), e `widgetType` presente
|
||||
quando `elType==='widget'`. **Não é chamada por nenhuma das 10 classes deste doc directamente**
|
||||
(não aparece em nenhuma das leituras de `add-container`/`add-*-widget`/`build-page`) — é
|
||||
provavelmente invocada num caminho de import/validação mais genérico não coberto por esta
|
||||
tarefa (possivelmente `import-sandbox-artifact` ou o dispatcher de validação de widgets custom
|
||||
do doc 06, dado o allowlist incluir tipos `e-form-*` que não aparecem em mais nenhum ficheiro
|
||||
lido nesta tarefa). Não confundir com `EMCP_Tools_Settings_Validator` (validador de VALORES de
|
||||
settings contra o schema de controls de um widget — usado por `Widget_Abilities::execute_add_widget()`,
|
||||
ver §3) nem com `EMCP_Tools_Atomic_Props` (coerção/validação de tipos de prop atómico v4 — usado
|
||||
extensivamente por `EMCP_Tools_Data`, coberto em detalhe no doc 02).
|
||||
|
||||
---
|
||||
|
||||
## Blueprint para réplica
|
||||
|
||||
### Copiar quase 1:1 (baixo risco de reescrever pior)
|
||||
|
||||
- **`EMCP_Tools_Element_Factory`** — fábrica pura, sem I/O; as duas normalizações estáticas
|
||||
(`normalize_container_settings`, `normalize_background_settings`) codificam conhecimento
|
||||
tácito real sobre onde o Elementor lê cada chave (não documentado nem no Elementor nem em
|
||||
lado nenhum público) — recriá-las do zero significa redescobrir os mesmos 3-4 bugs (#32 em
|
||||
particular) por tentativa e erro num site de produção.
|
||||
- **O padrão try/save/verify/fallback de `save_page_data()`** — é o resultado de pelo menos 5
|
||||
issues reais numeradas (#98, #101, #102, #105, #111, #112) resolvidas ao longo de várias
|
||||
versões. Uma réplica que escreva `_elementor_data` só com `update_post_meta()` direto (sem
|
||||
passar primeiro pelo `Document::save()` nativo) perde a regeneração de CSS automática do
|
||||
Elementor; uma que só use `Document::save()` sem fallback nem verificação pós-save vai falhar
|
||||
silenciosamente em contexto CLI/REST exactamente como a v1.0.0 original deste plugin
|
||||
presumivelmente fazia antes destas correcções serem adicionadas.
|
||||
- **O hook `init()` de invalidação de `_elementor_element_cache`** — 4 linhas de código que
|
||||
previnem uma classe inteira de bugs "página fica vazia depois de criada via MCP" em sites com
|
||||
object cache persistente. Trivial de replicar, caro de não ter.
|
||||
- **O padrão de `Global_Classes_Write_Abilities`: read-mutate-put via o repositório oficial do
|
||||
Elementor** em vez de escrita directa de meta — delega o cálculo de diff e limpeza de relações
|
||||
ao próprio Elementor. Reescrever isto por fora (calcular o diff manualmente) só faz sentido se
|
||||
se estiver a substituir inteiramente o sistema de Class Manager, não a interagir com ele.
|
||||
|
||||
### Simplificar na reescrita
|
||||
|
||||
- **Os 3 tools Pro de `Custom_Code_Abilities`** (`add-custom-css`, `add-code-snippet`,
|
||||
`list-code-snippets`) dependem de comportamento interno específico do Elementor Pro (CPT
|
||||
`elementor_snippet`, meta keys `_elementor_location`/`_elementor_priority`/`_elementor_code`).
|
||||
Numa réplica sem o objectivo de espelhar exactamente o Elementor Pro, um sistema de snippets
|
||||
site-wide próprio (CPT nosso, sem tentar imitar o formato do Elementor Pro) é mais simples de
|
||||
manter e não fica preso a mudanças não documentadas do formato interno de um plugin de
|
||||
terceiros.
|
||||
- **Os 6 tools Pro de `Template_Abilities`** (theme templates, dynamic tags, popups) só fazem
|
||||
sentido se se estiver mesmo a espelhar o Elementor Pro Theme Builder; se a réplica não visa
|
||||
paridade total com Elementor Pro, este bloco inteiro pode ficar de fora sem perda de valor
|
||||
para o caso de uso "gerar/editar páginas com Elementor free".
|
||||
- **`normalize_node()` (shorthand coercion em `build-page`)** — é uma correcção pragmática para
|
||||
modelos de IA fracos, não uma necessidade estrutural. Numa réplica visada a modelos fortes
|
||||
(ex. só Claude/GPT-4 classe), pode-se optar por rejeitar shorthand com erro claro em vez de
|
||||
coagir silenciosamente — troca tolerância por previsibilidade; ambas são escolhas válidas,
|
||||
mas a escolha deve ser deliberada, não copiada por omissão.
|
||||
|
||||
### Riscos/gotchas a não esquecer (lista consolidada, por nº de issue)
|
||||
|
||||
| # | Onde | Risco se ignorado numa réplica |
|
||||
|---|---|---|
|
||||
| #32 | `normalize_container_settings` | Atalhos `justify_content`/`align_items` gravados mas nunca lidos pelo gerador CSS — alinhamento nunca aplica no frontend |
|
||||
| #38 | `save_elementor_conditions` | Bypass da API de condições do Theme Builder invalida location cache de TODOS os templates, não só o alterado |
|
||||
| #72/#73/#92 | `update_element_settings` (styles/editor_settings hoist + sync_local_class_refs) | Estilos locais v4 gravados mas nunca aplicados; rótulo Navigator gravado em chave morta |
|
||||
| #83 | `full_bleed_preset` | Faixas brancas em templates Canvas com secções full-width |
|
||||
| #97 | `reassign_element_ids` | Duplicar um elemento atómico faz o duplicado herdar (e poluir) as classes locais do original |
|
||||
| #98 | `save_page_data` verificação pós-save | `Document::save()` pode devolver sucesso e mesmo assim não persistir nada em contexto 4.x/REST |
|
||||
| #101/#102 | `coerce_settings`/`coerce_tree` | Um valor bruto num prop atómico tranca TODA a página fora de futuros saves, não só o elemento afectado |
|
||||
| #104/#72(layout) | `is_container_type` | Ferramentas de layout que só reconhecem `container` legado ignoram containers atómicos v4 (`e-flexbox`/`e-div-block`) |
|
||||
| #105 | `elementor_documents_ready` | Fatal error por null-deref se o Elementor ainda não completou o próprio boot |
|
||||
| #108 | `Global_Classes_Write_Abilities` (comentário de cabeçalho) | Tools de escrita de Global Classes são recentes (3.9.0) — API do Elementor para isto é jovem, sujeita a mudança |
|
||||
| #111 | `is_container_supported` (2 sítios: add-container e build-page) | Container gravado com sucesso mas invisível no frontend se a experiência Flexbox Container estiver desligada — falha totalmente silenciosa |
|
||||
| #112 | `is_atomic_validation_rejection` | Uma dynamic tag não resolvível em contexto CLI bloqueia save de página inteira, incluindo edições não relacionadas |
|
||||
| F-004 | `add-custom-css` sanitização | Bypass de `</style>` como vector XSS se a remoção não for feita em loop até fixpoint |
|
||||
| F-008 | Regex de handlers `on*=` (SVG + custom CSS) | Handler com valor multi-linha escapa à sanitização sem a flag `/s` |
|
||||
|
||||
---
|
||||
|
||||
## Fonte
|
||||
|
||||
Leitura directa (19-08-2026) de, sob
|
||||
`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`:
|
||||
|
||||
- `includes/abilities/class-page-abilities.php`
|
||||
- `includes/abilities/class-layout-abilities.php`
|
||||
- `includes/abilities/class-widget-abilities.php`
|
||||
- `includes/abilities/class-template-abilities.php`
|
||||
- `includes/abilities/class-global-abilities.php`
|
||||
- `includes/abilities/class-global-classes-abilities.php`
|
||||
- `includes/abilities/class-global-classes-write-abilities.php`
|
||||
- `includes/abilities/class-custom-code-abilities.php`
|
||||
- `includes/abilities/class-svg-icon-abilities.php`
|
||||
- `includes/abilities/class-composite-abilities.php`
|
||||
- `includes/class-element-factory.php`
|
||||
- `includes/class-elementor-data.php`
|
||||
- `includes/validators/class-element-validator.php`
|
||||
- `includes/abilities/class-ability-registrar.php` (linhas 430-530, só para confirmar a condição
|
||||
de registo — `if ( $elementor_active )` — e a ausência de qualquer classe de CRUD de widget
|
||||
custom neste bloco)
|
||||
|
||||
Cruzado com `docs/00-ARQUITECTURA.md` (mesma tarefa, doc irmão) para o contrato de
|
||||
`emcp_tools_register_ability()` e a ordem de arranque, e com `skill://emcp-tools` (auditoria de
|
||||
segurança, 16-08-2026) para a lista de tools deste grupo presentes no deny-list por omissão
|
||||
(§2.6/§2.9/§2.10 desse documento).
|
||||
@@ -0,0 +1,515 @@
|
||||
# 02 — Elementor Atomic v4 (widgets/layout/global classes de leitura) e Gutenberg nativo
|
||||
|
||||
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
|
||||
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
|
||||
19-08-2026. Complementa `docs/00-ARQUITECTURA.md` (arquitectura geral, contrato de
|
||||
`emcp_tools_register_ability()`) e `skill://emcp-tools` (postura de segurança/deny-list).
|
||||
|
||||
## 0. Panorama e gating
|
||||
|
||||
Este documento cobre três classes de abilities e seis serviços de suporte. Todas as três
|
||||
classes de abilities são instanciadas em `EMCP_Tools_Ability_Registrar::register_groups()`
|
||||
(`includes/abilities/class-ability-registrar.php`), mas com gating muito diferente:
|
||||
|
||||
| Classe | Onde é instanciada no registrar | Gate adicional dentro da própria classe |
|
||||
|---|---|---|
|
||||
| `EMCP_Tools_Atomic_Widget_Abilities` | Dentro do bloco `if ( $elementor_active )` | `register()` faz `return` cedo se `EMCP_Tools_Atomic_Props::is_atomic_supported()` for `false` — **nenhuma** das 10 tools regista |
|
||||
| `EMCP_Tools_Atomic_Layout_Abilities` | Dentro do bloco `if ( $elementor_active )` | Mesmo guard `is_atomic_supported()` — e, ao contrário do que a doc-comment do código sugere, isto também bloqueia `detect-elementor-version` (ver §3, gotcha) |
|
||||
| `EMCP_Tools_Gutenberg_Abilities` | Na secção "always-on" (topo de `register_groups()`), **sem** verificar `$elementor_active` | Nenhum — regista sempre, mesmo com Elementor completamente ausente/inactivo |
|
||||
|
||||
**Consequência prática:** num site com Elementor activo mas sem o Elementor 4.0+/atomic
|
||||
ligado (a maioria dos sites em 2026, dado que `is_atomic_supported()` não é uma simples
|
||||
verificação de versão — ver §4), as 19 tools atomic (10 + 3, menos 1 sobreposta, ver tabelas)
|
||||
não existem de todo no `wp_get_abilities()`; as 10 tools Gutenberg existem sempre, com ou sem
|
||||
Elementor.
|
||||
|
||||
---
|
||||
|
||||
## 1. `EMCP_Tools_Atomic_Widget_Abilities` — `includes/abilities/class-atomic-widget-abilities.php`
|
||||
|
||||
**Condição de registo:** classe instanciada só quando `$elementor_active` é `true`; dentro
|
||||
dela, `register()` só prossegue se `EMCP_Tools_Atomic_Props::is_atomic_supported()` devolver
|
||||
`true` (ver §4 para o mecanismo de detecção).
|
||||
|
||||
Duas tools "universais" (aceitam qualquer `widget_type` atómico com settings em bruto no
|
||||
formato `$$type`) mais oito tools de conveniência (uma por widget atómico, com parâmetros
|
||||
planos que a própria classe converte para `$$type` via `EMCP_Tools_Atomic_Widget_Map`).
|
||||
|
||||
| Tool | Input schema (resumo) | O que faz | `permission_callback` | readonly / destructive / idempotent |
|
||||
|---|---|---|---|---|
|
||||
| `add-atomic-widget` | `post_id`(int,req), `parent_id`(string,req), `position`(int, -1=append), `widget_type`(string,req, ex. `e-heading`), `settings`(object, valores já em `$$type`) | Tool genérica: cria o elemento via `EMCP_Tools_Element_Factory::create_atomic_widget()` com settings passados tal-e-qual (sem conveniência), insere no `parent_id` na `position` dada, grava a página. | `check_edit_permission` (`edit_posts` + `edit_post` do `post_id` se dado) | false / false / false |
|
||||
| `update-atomic-widget` | `post_id`(int,req), `element_id`(string,req), `settings`(object,req, `$$type`-wrapped) | Merge PARCIAL de settings num widget atómico já existente (só as chaves fornecidas mudam) via `EMCP_Tools_Data::update_element_settings()`. | `check_edit_permission` | false / false / **true** |
|
||||
| `add-atomic-heading` | `post_id`,`parent_id`(req), `position`, `title`, `tag`(enum h1-h6, default h2), `link`, `css_id` | Widget `e-heading`. Mapeia `title`→prop `title` (html-v3), `tag`→prop `tag` (string). | `check_edit_permission` | false / false / false |
|
||||
| `add-atomic-paragraph` | `post_id`,`parent_id`(req), `position`, `content`, `link`, `css_id` | Widget `e-paragraph`. **Gotcha:** a prop real chama-se `paragraph`, não `text` — ver §5. | `check_edit_permission` | false / false / false |
|
||||
| `add-atomic-button` | `post_id`,`parent_id`(req), `position`, `text`, `link`, `target_blank`(bool), `css_id` | Widget `e-button`. `link` aceita `target_blank`. | `check_edit_permission` | false / false / false |
|
||||
| `add-atomic-image` | `post_id`,`parent_id`(req), `position`, `image_id`(int) OU `image_url`(string), `alt`, `link`, `css_id` | Widget `e-image`. `image_id` XOR `image_url`. Para `image_id`, o `alt` é escrito em `_wp_attachment_image_alt` (não na prop) — ver §5. | `check_edit_permission` | false / false / false |
|
||||
| `add-atomic-svg` | `post_id`,`parent_id`(req), `position`, `svg_id`(int) OU `svg_url`(string), `css_id` | Widget `e-svg`. Usa o tipo `svg-src`, distinto de `image-src`. | `check_edit_permission` | false / false / false |
|
||||
| `add-atomic-youtube` | `post_id`,`parent_id`,`video_url`(**todos req**), `position`, `css_id` | Widget `e-youtube`. `source` é uma prop STRING simples (não um shape). | `check_edit_permission` | false / false / false |
|
||||
| `add-atomic-video` | `post_id`,`parent_id`(req), `position`, `video_url`(string) OU `video_id`(int), `css_id` | Widget `e-self-hosted-video`. `source` é o shape `video-src` (XOR id/url) — diferente de `add-atomic-youtube`. | `check_edit_permission` | false / false / false |
|
||||
| `add-atomic-divider` | `post_id`,`parent_id`(req), `position`, `css_id` | Widget `e-divider`. Sem conteúdo próprio; só a cauda partilhada (link/css_id/classes, mas divider não usa link na prática). | `check_edit_permission` | false / false / false |
|
||||
|
||||
**Mecanismo partilhado das 8 convenience tools:** `register_atomic_convenience()` monta um
|
||||
schema comum (`post_id`,`parent_id`,`position` + os `extra_props` de cada widget) e um
|
||||
`execute_callback` genérico que: (1) chama `$settings_fn($input)` — um closure que invoca
|
||||
`EMCP_Tools_Atomic_Widget_Map::settings($widget_type, $input)`; (2) constrói o elemento via
|
||||
`$this->factory->create_atomic_widget()`; (3) se o input tiver parâmetros de estilo comuns
|
||||
(`padding`, `background_color`, `min_height`, etc.), constrói-os via
|
||||
`EMCP_Tools_Atomic_Styles::build_common_props()` e aplica-os como uma classe de estilo local
|
||||
via `create_local_class()` + `apply_to_element()`; (4) insere e grava. Isto significa que
|
||||
**qualquer** convenience tool aceita implicitamente os parâmetros de estilo comuns
|
||||
(`padding`, `background_color`, `min_height`, `width`, `border_radius`, `color`, etc.) mesmo
|
||||
que não apareçam no `extra_props` explícito de cada tool individual, porque
|
||||
`build_common_props()` corre sobre o `$input` inteiro.
|
||||
|
||||
`check_edit_permission($input)`: requer `current_user_can('edit_posts')`; se `post_id` for
|
||||
fornecido e não-zero, requer adicionalmente `current_user_can('edit_post', $post_id)`.
|
||||
|
||||
---
|
||||
|
||||
## 2. `EMCP_Tools_Atomic_Layout_Abilities` — `includes/abilities/class-atomic-layout-abilities.php`
|
||||
|
||||
**Condição de registo:** idêntica à classe anterior — instanciada só com `$elementor_active`,
|
||||
e `register()` faz `return` cedo se `is_atomic_supported()` for `false`. Ver §3 para o gotcha
|
||||
sobre `detect-elementor-version`.
|
||||
|
||||
| Tool | Input schema (resumo) | O que faz | `permission_callback` | readonly / destructive / idempotent |
|
||||
|---|---|---|---|---|
|
||||
| `add-flexbox` | `post_id`(req), `parent_id`(vazio=top-level), `position`, `tag`(enum div/header/section/article/aside/footer), `direction`(row/column/…), `justify`, `align`, `gap`+`gap_unit`, `wrap`, `css_id`, `padding`, `background_color`, `min_height` | Cria um container `e-flexbox` (Elementor 4.0+). As propriedades de layout (direction/justify/align/gap/wrap) e as comuns (padding/background/min-height) são extraídas de uma lista fixa de `style_keys` no `execute_add_flexbox()`, convertidas via `EMCP_Tools_Atomic_Styles`, e aplicadas como classe de estilo local — **não** via `register_atomic_convenience()` (esta tool tem o seu próprio `execute_callback`, não reutiliza o mecanismo da classe Widget). Se `parent_id` vazio, insere top-level (`array_splice`/append directo em vez de `insert_element()`). | `check_edit_permission` | false / false / false |
|
||||
| `add-div-block` | `post_id`(req), `parent_id`, `position`, `tag`(mesmo enum), `css_id`, `padding`, `background_color` | Cria um container `e-div-block` (layout de fluxo/bloco, NÃO flex) — para quando não se quer um flexbox. Mesmo padrão de inserção top-level vs `parent_id`. | `check_edit_permission` | false / false / false |
|
||||
| `detect-elementor-version` | Sem input (`properties: {}`) | Devolve `elementor_version` (`ELEMENTOR_VERSION`), `elementor_pro_version`, `supports_atomic` (via `is_atomic_supported()`), `supports_container` (via `is_container_supported()`), `recommended_mode` (`atomic`\|`legacy`\|`unsupported`) e, se `unsupported`, um `warning` a avisar que os experiments "Flexbox Container" / "Atomic Elements" estão ambos desligados e que páginas criadas via MCP vão gravar dados mas renderizar vazias. **Ver gotcha em baixo — na prática, esta tool só está disponível quando `recommended_mode` já seria `atomic`.** | closure inline: `current_user_can('edit_posts')` | **true** / false / **true** |
|
||||
|
||||
**GOTCHA de código encontrado (não documentado como tal no próprio ficheiro):** a doc-comment
|
||||
acima de `register_detect_elementor_version()` diz literalmente `// Detect version (always
|
||||
registers, even on < 4.0)`. Mas o método `register()` da classe é:
|
||||
|
||||
```php
|
||||
public function register(): void {
|
||||
if ( ! EMCP_Tools_Atomic_Props::is_atomic_supported() ) {
|
||||
return; // <-- sai ANTES de chamar register_detect_elementor_version()
|
||||
}
|
||||
$this->register_add_flexbox();
|
||||
$this->register_add_div_block();
|
||||
$this->register_detect_elementor_version();
|
||||
}
|
||||
```
|
||||
|
||||
O `return` cedo bloqueia as TRÊS chamadas, incluindo a de `detect-elementor-version` — pelo
|
||||
que esta tool só existe quando o site JÁ suporta atomic, exactamente o cenário oposto ao mais
|
||||
útil (um agente que precisa de descobrir se deve usar tools legacy ou atomic não consegue
|
||||
chamar esta tool quando mais precisa dela — nos sites em `legacy`/`unsupported` a tool
|
||||
simplesmente não aparece em `wp_get_abilities()`). Numa réplica, isto seria trivial de
|
||||
corrigir: mover `register_detect_elementor_version()` para fora do guard (registá-la sempre,
|
||||
independentemente de `is_atomic_supported()`).
|
||||
|
||||
---
|
||||
|
||||
## 3. `EMCP_Tools_Gutenberg_Abilities` — `includes/abilities/class-gutenberg-abilities.php`
|
||||
|
||||
**Condição de registo:** **sempre** — está na secção "always-on" do registrar
|
||||
(`$gutenberg = new EMCP_Tools_Gutenberg_Abilities(); $gutenberg->register();`), sem nenhum
|
||||
`if ($elementor_active)` nem verificação de módulo. É pura WordPress core: opera sobre
|
||||
`post_content` de qualquer post via `parse_blocks()`/`serialize_blocks()` (funções nativas do
|
||||
WP) e `EMCP_Tools_Block_Tree` (§7). Dez tools no total, desenhadas como um fluxo
|
||||
discover→schema→edit incremental por PATH.
|
||||
|
||||
| Tool | Input schema (resumo) | O que faz | `permission_callback` | readonly / destructive / idempotent |
|
||||
|---|---|---|---|---|
|
||||
| `list-blocks` | `category`, `search` (ambos opcionais) | Lista block types registados via `WP_Block_Type_Registry::get_instance()->get_all_registered()`, filtrável por categoria/substring em nome+título. Devolve `{name,title,category}` por linha. Passo 1 do fluxo "construir página de blocos". | `check_read_permission` | true / false / true |
|
||||
| `get-block-schema` | `name`(string) OU `names`(string[], lote) | Devolve `{name,title,category,attributes,supports,example}` por block type — `example` é um snippet mínimo de markup gerado (`<!-- wp:{short} -->…<!-- /wp:{short} -->`). Passo 2, antes de `add-block`. Nomes não registados devolvem `{name,error}` em vez de falhar o lote inteiro. | `check_read_permission` | true / false / true |
|
||||
| `get-post-blocks` | `post_id`(req), `depth`(opcional, limita profundidade) | Devolve a árvore de blocos do post com um PATH de índices por bloco (ex. `[2,1]`), via `EMCP_Tools_Block_Tree::from_markup()`+`summarize()`. **Chamada obrigatória antes de qualquer `update-block`/`remove-block`/`move-block`/`duplicate-block`** para obter os paths actuais. | `check_read_permission` | true / false / true |
|
||||
| `list-patterns` | `search`, `category` (opcionais) | Lista block patterns registados via `WP_Block_Patterns_Registry`, filtrável. Devolve `{name,title,categories,description}`. | `check_read_permission` | true / false / true |
|
||||
| `add-block` | `post_id`(req), `markup`(string,req, pode conter vários blocos), `position`({mode,path}) | Insere markup Gutenberg bruto numa posição. `position.mode`: `append`\|`prepend`\|`before`\|`after`\|`inside` (os últimos três exigem `position.path`, resolvido via `get-post-blocks`). Valida que o `path` resolve para um bloco antes de inserir. | `check_write_permission` | false / false / false |
|
||||
| `update-block` | `post_id`(req), `path`(int[],req), `markup`(string,req) | Substitui o bloco no `path` por novo markup (pode expandir para vários blocos). | `check_write_permission` | false / false / false |
|
||||
| `remove-block` | `post_id`(req), `path`(int[],req) | Apaga o bloco no `path` (e os seus `innerBlocks`). **Única tool Gutenberg marcada `destructive:true`.** | `check_write_permission` | false / **true** / false |
|
||||
| `move-block` | `post_id`(req), `path`(int[],req), `position`({mode,path},req) | Move o bloco de `path` para uma nova posição. Delegado a `EMCP_Tools_Block_Tree::move()`, que tem guards de segurança próprios (ver §7). | `check_write_permission` | false / false / false |
|
||||
| `duplicate-block` | `post_id`(req), `path`(int[],req) | Clona o bloco no `path`, insere a cópia imediatamente a seguir. Devolve o `path` da cópia (calculado como `path` com o último índice +1 — assume que `duplicate()` sempre insere logo a seguir ao original no mesmo nível). | `check_write_permission` | false / false / false |
|
||||
| `insert-pattern` | `post_id`(req), `pattern_name`(string,req, de `list-patterns`), `position`({mode,path}) | Insere um pattern registado (resolvido via `WP_Block_Patterns_Registry`) numa posição, expandindo o `content` do pattern para blocos via `parse_blocks()`. | `check_write_permission` | false / false / false |
|
||||
|
||||
**Permissões:** `check_read_permission($input)` requer `edit_posts`, mais `edit_post($post_id)`
|
||||
se `post_id` for dado (mas não é required em todas as tools de leitura — só `get-post-blocks`
|
||||
o exige no schema). `check_write_permission($input)` é mais estrito: requer `post_id`
|
||||
**presente e não-zero** e `edit_post($post_id)` — nunca aceita uma escrita sem `post_id`
|
||||
concreto.
|
||||
|
||||
**Persistência (`save_tree()`):** todas as seis tools de escrita convergem em `save_tree()`,
|
||||
que faz `wp_update_post(['ID'=>…, 'post_content'=>wp_slash(Block_Tree::to_markup($tree))])`.
|
||||
**Gotcha citado no código:** `wp_update_post()` corre `wp_unslash()` sobre os dados, e a
|
||||
serialização de blocos emite escapes de barra invertida (`&`, `\"`, `\\`, …) nos atributos —
|
||||
por isso o markup TEM de ser "slashed" antes de chegar a `wp_update_post()`, senão esses
|
||||
escapes são removidos e o bloco corrompe-se. `save_tree()` também regista a alteração no
|
||||
change-ledger via `EMCP_Tools_Change_Recorder::record_post_fields()` (domínio `gutenberg`,
|
||||
action `block-write`), guardando o `post_content` ANTERIOR — é o que permite `rollback-change`
|
||||
(doc 05) desfazer uma edição de blocos.
|
||||
|
||||
---
|
||||
|
||||
## 4. Serviços de suporte
|
||||
|
||||
### 4.1 `EMCP_Tools_Atomic_Props` — `includes/class-atomic-props.php` (973 linhas)
|
||||
|
||||
O coração do sistema `$$type`. Todo valor de prop atómico Elementor 4.0+ é um envelope
|
||||
`{ '$$type': '<tipo>', 'value': <dados> }` — o objectivo desta classe é (a) construir esses
|
||||
envelopes a partir de valores simples que um agente de IA escreveria naturalmente, e (b)
|
||||
fazer o caminho inverso para leitura (`unwrap()`), mais (c) uma camada de auto-correcção que
|
||||
salvou o plugin de uma classe inteira de bugs de produção (ver os números de issue citados
|
||||
no próprio código).
|
||||
|
||||
**Builders de envelope (métodos estáticos, um por tipo primitivo/composto):**
|
||||
|
||||
| Método | Tipo `$$type` produzido | Nota de design |
|
||||
|---|---|---|
|
||||
| `string($v)` | `string` | Trivial. |
|
||||
| `number($v)` | `number` | Trivial. |
|
||||
| `boolean($v)` | `boolean` | Trivial. |
|
||||
| `size($size,$unit='px')` | `size` → `{size,unit}` | Usado para qualquer dimensão CSS. |
|
||||
| `color($color)` | `color` | **Não** é `string` — a prop `color` é um `Color_Prop_Type` e exige o envelope `color`; um `string` é rejeitado. |
|
||||
| `background_color($color)` | `background` → `{color: <color-prop>}` | Não existe prop `background-color` — o Elementor guarda fundo como `Background_Prop_Type` cujo campo `color` é ele próprio um `color`-prop aninhado. Escrever `background-color` directamente é silenciosamente descartado. |
|
||||
| `dimensions($sides)` | `dimensions` → `{block-start,block-end,inline-start,inline-end}` | Shape partilhado por `padding`/`margin`. Não existe prop `padding-block-start` individual — construir por lado sem usar este wrapper é descartado no save. |
|
||||
| `html($text)` | `html-v3` → `{content:<string-prop>, children:[]}` | Usado para qualquer conteúdo de texto rico (heading, paragraph, button…). O nome do tipo já evoluiu `html`→`html-v2`→`html-v3`; a classe segue a Elementor como fonte de verdade em vez de fixar o nome (ver `coerce_against_prop`). |
|
||||
| `url($url)` | `url` | Trivial. |
|
||||
| `link($url,$target_blank=false)` | `link` → `{destination:<url-prop>, tag:<string-prop>('a'), isTargetBlank?:<boolean-prop>}` | `isTargetBlank` só é incluído quando `true` (omitido, não `false`, quando não pedido). |
|
||||
| `classes($ids=[])` | `classes` | Array de IDs de classe (locais `e-*` ou globais `g-*`). |
|
||||
| `image($id,$url='',$alt='')` | `image` → `{src:{$$type:'image-src', value:{id,url}}}` | `id` XOR `url` — `Image_Src_Prop_Type` exige exactamente um dos dois, o outro TEM de ser `null` (não omitido). Passar ambos, ou um `id` como `number` em vez de `image-attachment-id`, produz `image: invalid_value` (issue #74). `alt` só entra no envelope quando é uma imagem por `url`; para attachment é ignorado pelo Elementor (renderiza sempre o alt da media library) — ver `EMCP_Tools_Atomic_Widget_Map::image()`. |
|
||||
| `video_src($id,$url='')` | `video-src` → `{id:{$$type:'video-attachment-id',...}}` OU `{url:<url-prop>}` | Shape distinto de `image-src`; um envelope `url` simples faz o Elementor **rejeitar o elemento inteiro** (`source: invalid_value`) em vez de só ignorar o valor — foi o que impedia `add-atomic-video` de funcionar de todo no Elementor 4.2 antes desta correcção. |
|
||||
| `svg($id,$url='')` | `svg-src` | **Tipo distinto** de `image-src` — usar `image()` para um `e-svg` falha (issue #74). |
|
||||
|
||||
**Introspecção de schema (`props_schema()`):** em vez de fixar hard-coded que prop cada
|
||||
widget espera, a classe pergunta directamente ao próprio Elementor:
|
||||
`\Elementor\Plugin::$instance->widgets_manager->get_widget_types($widget_type)::get_props_schema()`,
|
||||
com cache estática por `$widget_type` (uma passagem de coerção sobre uma página inteira
|
||||
pergunta pelo mesmo punhado de schemas centenas de vezes).
|
||||
|
||||
**A camada de auto-correcção (`coerce_settings`/`coerce_with_schema`/`coerce_tree`):** um
|
||||
agente de IA vai escrever naturalmente `'title' => 'Hello'` em vez do envelope
|
||||
`{'$$type':'html-v3', value:{...}}`. Sem correcção, o Elementor cai para o valor por omissão
|
||||
da prop (elemento renderiza texto placeholder) e **todo save subsequente dessa página passa a
|
||||
falhar** com `Settings validation failed` — a página fica impossível de editar tanto via API
|
||||
como via editor (issue #101). A correcção:
|
||||
|
||||
1. `apply_prop_aliases()` — renomeia chaves alias (`text`/`content`/`heading` → o nome
|
||||
canónico `title`) usando a MESMA metadata que o Elementor expõe (`$prop->get_meta_item('aliases')`),
|
||||
**antes** de qualquer validação. Crítico: o `Props_Parser` do Elementor **descarta
|
||||
silenciosamente** chaves que não reconhece (não rejeita, apaga) — por isso uma chave alias
|
||||
não corrigida a tempo perde o conteúdo em vez de ser rejeitada com erro visível (issue #102).
|
||||
Um valor já presente sob o nome canónico nunca é substituído por um alias.
|
||||
2. `coerce_against_prop()`/`candidates_for()`/`coerce_shape()` — para cada prop, se o valor
|
||||
já for aceite por `$prop->validate()`, fica como está; senão constrói candidatos a partir
|
||||
dos próprios `get_prop_types()`/`get_key()`/`get_shape()` do prop Elementor (nunca hardcoded)
|
||||
e testa cada um contra `validate()`, usando o primeiro aceite. Cobre tanto valores planos
|
||||
(string→envelope certo) como shapes compostos (ex. `link` legado `{url,is_external}` →
|
||||
`{destination,isTargetBlank}`).
|
||||
3. `coerce_tree()` — corre sobre a ÁRVORE INTEIRA no save, não só o elemento tocado, porque o
|
||||
Elementor valida a página inteira de uma vez: um único widget por corrigir, em qualquer
|
||||
parte da página, bloqueava até a própria edição destinada a reparar a página (issue #102).
|
||||
|
||||
**`unwrap()`/`unwrap_array()`:** direcção inversa — usado por `get-element-settings` (doc 01)
|
||||
para devolver valores planos e legíveis a um agente em vez do envelope `$$type` bruto.
|
||||
|
||||
**`is_atomic_supported()` / `is_container_supported()`:** a peça mais subtil de todo o
|
||||
ficheiro. **Não** é baseada em `version_compare(ELEMENTOR_VERSION, '4.0.0', '>=')` — o
|
||||
Elementor lança o atomic/v4 como experiment opt-in enquanto `ELEMENTOR_VERSION` continua a
|
||||
reportar um valor 3.x. O sinal AUTORITATIVO é se os TIPOS de elemento `e-flexbox`/`e-div-block`
|
||||
estão realmente REGISTADOS (`$elementor->elements_manager->get_element_types()`), porque é
|
||||
isso que garante que `Document::save()` preserva os dados em vez de os sanitizar
|
||||
silenciosamente. Deliberadamente NÃO usa o experiment `e_opt_in_v4_page` (que liga o EDITOR
|
||||
v4 sem garantir que os tipos de elemento estão registados — um site pode ter esse experiment
|
||||
ligado e `e_atomic_elements` desligado, escrever "com sucesso" e `_elementor_data` fica vazio
|
||||
após o save). Cai depois para os experiments `e_atomic_elements`/`atomic_widgets`, e só por
|
||||
último para o `version_compare` genérico (fallback forward-compatible). Comentário explícito
|
||||
no código: "NB: do NOT use `class_exists('\Elementor\Modules\AtomicWidgets\Module')` as a
|
||||
signal — that class is autoloaded even when the atomic experiment is OFF". `is_container_supported()`
|
||||
segue o mesmo padrão para o experiment legado (3.x) `container`.
|
||||
|
||||
### 4.2 `EMCP_Tools_Atomic_Widget_Map` — `includes/class-atomic-widget-map.php`
|
||||
|
||||
Mapa único de "parâmetros amigáveis → settings `$$type`", partilhado por §1 (convenience
|
||||
tools) E pela tool composta `build-page` (doc 01) — razão de existir: `build-page` passava
|
||||
settings de widgets atómicos em bruto, e como props complexas (`e-image`.`image`,
|
||||
`e-self-hosted-video`.`source`) não têm chave equivalente em bruto, o widget ficava vazio. Ao
|
||||
centralizar aqui, ambos os caminhos produzem settings byte-idênticas para o mesmo input.
|
||||
|
||||
`atomic_types()`: os 8 tipos conhecidos — `e-heading`, `e-paragraph`, `e-button`, `e-image`,
|
||||
`e-svg`, `e-youtube`, `e-self-hosted-video`, `e-divider`. `settings($widget_type,$params)`
|
||||
despacha para um builder privado por tipo; `is_atomic($widget_type)` verifica pertença.
|
||||
|
||||
| Builder | Gotcha documentado no código |
|
||||
|---|---|
|
||||
| `heading()` | Directo — `title`→html, `tag`→string. |
|
||||
| `paragraph()` | **A prop chama-se `paragraph`, não `text`** (Html_V3) — escrever `text` apagava o conteúdo silenciosamente (issue #56). |
|
||||
| `button()` | Directo, mas passa `$link_target_blank=true` ao `finish()` partilhado (só o botão honra `target_blank`). |
|
||||
| `image()` | `image_id` XOR `image_url`. Para `image_id`, escreve o `alt` em `update_post_meta($image_id, '_wp_attachment_image_alt', $alt)` — a única forma que faz efeito, porque `e-image` não tem prop `alt` de topo e para uma attachment o Elementor renderiza sempre o alt da media library. |
|
||||
| `svg()` | Usa `EMCP_Tools_Atomic_Props::svg()` (tipo `svg-src`), nunca `image()`. |
|
||||
| `youtube()` | `source` é `EMCP_Tools_Atomic_Props::string()` — um **union de string simples**, não um shape. |
|
||||
| `video()` | `source` é `EMCP_Tools_Atomic_Props::video_src()` — um **shape XOR id/url**, distinto de `youtube()` apesar do nome de prop idêntico (`source`). Um envelope `url` simples faz o Elementor recusar o elemento inteiro. |
|
||||
| `divider()` | Vazio — só a cauda partilhada. |
|
||||
|
||||
`finish($settings,$params,$link_target_blank=false)`: cauda partilhada por todos os
|
||||
builders — adiciona `link` (se presente, com `esc_url_raw()`), `_cssid` (se presente, com
|
||||
`sanitize_text_field()`), e sempre `classes` (vazio, ponto de ancoragem para
|
||||
`EMCP_Tools_Atomic_Styles::apply_to_element()` adicionar depois uma classe local).
|
||||
|
||||
### 4.3 `EMCP_Tools_Atomic_Styles` — `includes/class-atomic-styles.php`
|
||||
|
||||
Constrói e aplica o mecanismo v4 de "classe de estilo local": em vez de propriedades CSS
|
||||
inline no elemento, o v4 guarda estilo num mapa `styles` no próprio elemento, referenciado por
|
||||
ID de classe em `settings.classes.value[]`.
|
||||
|
||||
- `create_local_class($element_id,$props,$breakpoint='desktop',$state=null)` — constrói UM
|
||||
variant (par breakpoint+state) de uma definição de classe: `{id,label:'local',type:'class',
|
||||
variants:[{meta:{breakpoint,state}, props, custom_css:null}]}`.
|
||||
- `mint_class_id($element_id)` — gera `e-<element_id>-<7hex>`; o ID incorpora deliberadamente
|
||||
o ID do elemento dono, porque as classes locais v4 pertencem a um único elemento.
|
||||
- `remap_local_classes(&$element)` — **corrige um bug real de duplicação (issue #97):**
|
||||
quando um elemento é duplicado com um `id` novo, as suas classes locais v4
|
||||
(`e-<id-antigo>-<hash>`) continuam a embutir o `id` de ORIGEM e ficam partilhadas com a
|
||||
fonte — uma escrita posterior no mapa `styles` sangra entre os dois, e o popover "Style
|
||||
Origin" do editor mostra entradas duplicadas. Este método re-minta as chaves do mapa
|
||||
`styles` (e o `id` de cada `style_def`) contra o `id` ACTUAL do elemento, e repõe
|
||||
`settings.classes.value` das IDs antigas para as novas — só toca em classes LOCAIS deste
|
||||
elemento; classes globais (`g-…`) referenciadas ficam intocadas.
|
||||
- `build_flex_props($params)` — mapeia parâmetros planos (`direction`/`flex_direction`,
|
||||
`justify`/`justify_content`, `align`/`align_items`, `wrap`/`flex_wrap`, `gap`+`gap_unit`,
|
||||
`row_gap`, `column_gap`) para props CSS `$$type` em kebab-case (`flex-direction`,
|
||||
`justify-content`, …).
|
||||
- `build_common_props($params)` — `width`/`min_height`/`border_radius` (size simples);
|
||||
`padding`/`margin` via `build_dimensions()` (shorthand de 4 lados — um valor único aplica
|
||||
aos 4 lados, `*_top/_right/_bottom/_left` definem por lado individualmente, o shorthand
|
||||
ganha se ambos presentes; **não existe prop `padding-block-start` individual, construir por
|
||||
lado sem este wrapper é descartado no save**); `background_color` (via
|
||||
`Atomic_Props::background_color()`, nunca uma prop `background-color`); `color` (via
|
||||
`Atomic_Props::color()`, nunca `string`).
|
||||
- `apply_to_element(&$element,$class_id,$style_def)` — push de `$class_id` em
|
||||
`element.settings.classes.value[]` e de `$style_def` em `element.styles[$class_id]`.
|
||||
|
||||
### 4.4 `EMCP_Tools_Widget_Loader` — `includes/class-widget-loader.php`
|
||||
|
||||
Fora do âmbito directo atomic/Gutenberg — pertence ao mecanismo Sandbox de widgets Elementor
|
||||
gerados (doc 06), mas foi incluído nesta batch de leitura. Padrão de design digno de nota:
|
||||
|
||||
- **Carregamento manifest-only** — nunca faz scan-and-include de um directório; lê um
|
||||
manifesto de widgets activos, verifica cada ficheiro contra o seu sha256 registado (guarda
|
||||
contra adulteração), e inclui dentro de isolamento de erro fatal.
|
||||
- **Shutdown handler de atribuição** — se um `include_once` disparar um fatal de
|
||||
compilação/parse (que um `try/catch` não apanha, porque um parse error num ficheiro incluído
|
||||
aborta o request), um `register_shutdown_function()` regista o `$this->loading` (post ID do
|
||||
widget a meio de inclusão) e, no shutdown, atribui o fatal a esse widget e desactiva-o —
|
||||
garantindo que um widget mau nunca consegue white-screenar o site repetidamente.
|
||||
- **Gate Pro total** — `has_access()` exige `emcp_tools_fs()->can_use_premium_code()`; num
|
||||
build Free/sem licença (como este), tanto `register_widgets()` como `register_assets()`
|
||||
saem imediatamente — é um NO-OP total neste site.
|
||||
- Regista handles de CSS/JS (`wp_register_style`/`wp_register_script`) só como metadata em
|
||||
`wp_enqueue_scripts` — o Elementor só enfileira efectivamente quando o widget está
|
||||
presente na página, mantendo o custo baixo mesmo com muitos widgets activos.
|
||||
|
||||
### 4.5 `EMCP_Tools_Widget_Catalog` + `includes/widgets/catalog-free.php` — `includes/widgets/class-widget-catalog.php`
|
||||
|
||||
Fonte única de metadata para widgets Elementor CLÁSSICOS (pré-4.0/não-atomic) — usada por
|
||||
`list-widgets`, `get-widget-schema`, `add-free-widget`, `add-pro-widget` (documentadas no
|
||||
doc 01, Elementor clássico). **Não** é usada pelas tools atomic desta doc (essas usam
|
||||
`EMCP_Tools_Atomic_Widget_Map` + introspecção ao vivo do `props_schema()` do Elementor).
|
||||
|
||||
`EMCP_Tools_Widget_Catalog::get()` funde três ficheiros de dados estáticos
|
||||
(`catalog-free.php`+`catalog-pro.php`+`catalog-woo.php`) num único array chaveado por
|
||||
`widget_type`, com cache em memória estática (`self::$catalog`). API de leitura:
|
||||
`get_widget($type)`, `all_types()`, `by_tier($tier)`, `tier_of($type)`, `is_pro($type)`,
|
||||
`search($query)` (substring case-insensitive sobre `type`+`title`+`use_case`+`keywords`, usado
|
||||
por `list-widgets` para pesquisa por intenção), `flush_cache()` (seam de teste).
|
||||
|
||||
**`catalog-free.php` — 680 linhas, 26 widgets clássicos gratuitos.** Cada entrada é um array
|
||||
com a forma:
|
||||
|
||||
```php
|
||||
'<widget_type>' => [
|
||||
'tier' => 'free',
|
||||
'title' => 'Nome legível',
|
||||
'category' => 'basic',
|
||||
'requires' => null, // ou o slug do plugin exigido (null nos gratuitos)
|
||||
'use_case' => 'Frase para pesquisa por intenção.',
|
||||
'keywords' => ['palavra1', 'palavra2', ...],
|
||||
'params' => [ 'nome_prop' => ['type'=>..., 'enum'=>[...], 'description'=>...], ... ],
|
||||
'required' => ['prop_obrigatoria'],
|
||||
'defaults' => ['prop' => valor],
|
||||
],
|
||||
```
|
||||
|
||||
Os 26 widgets: `heading`, `text-editor`, `image`, `button`, `video`, `icon`, `spacer`,
|
||||
`divider`, `icon-box`, `accordion`, `alert`, `counter`, `icon-list`, `image-box`,
|
||||
`image-carousel`, `progress`, `social-icons`, `star-rating`, `tabs`, `testimonial`, `toggle`,
|
||||
`html`, `menu-anchor`, `shortcode`, `rating`, `text-path`. Cada `params` é um schema
|
||||
simplificado mas fiel aos formatos NATIVOS de controlo Elementor (não `$$type` — isto é o
|
||||
formato clássico `_elementor_data`, ex.: `{size,unit}` para dimensões, `{url,is_external,
|
||||
nofollow}` para links, `{value,library}` para ícones, `yes`/`''` para toggles clássicos em
|
||||
vez de booleanos reais). Exemplo representativo (`button`): 24 params cobrindo texto, link,
|
||||
tamanho, tipo, alinhamento, ícone+posição, cores (normal/hover, fundo/texto/borda), animação
|
||||
de hover, borda (estilo/largura/cor/raio), box-shadow, tipografia completa (família,
|
||||
tamanho, peso, transform, letter-spacing), text-shadow, padding — o nível de detalhe é
|
||||
tipicamente 15-25 params por widget, reflectindo directamente os controlos Elementor reais.
|
||||
|
||||
**`catalog-pro.php` — 1049 linhas, 30 widgets Elementor Pro** (existência confirmada, conteúdo
|
||||
NÃO lido em detalhe por instrução de âmbito). Estrutura de dados idêntica a `catalog-free.php`
|
||||
(mesma forma de array, `tier'=>'pro'`, `requires'=>'elementor-pro'`). Pelos nomes das chaves
|
||||
top-level visíveis no ficheiro (sem ler os `params` internos): `form`, `posts`, `countdown`,
|
||||
`price-table`, `flip-box`, `animated-headline`, `call-to-action`, `slides`,
|
||||
`testimonial-carousel`, `price-list`, `gallery`, `share-buttons`, `table-of-contents`,
|
||||
`blockquote`, `lottie`, `hotspot`, `nav-menu`, `loop-grid`, `loop-carousel`, `media-carousel`,
|
||||
`nested-tabs`, `nested-accordion`, `portfolio`, `author-box`, `login`, `code-highlight`,
|
||||
`reviews`, `off-canvas`, `progress-tracker`, `search` — parecem cobrir formulários, grids de
|
||||
posts dinâmicos (loop), navegação, carrosséis multimédia, e widgets de UI avançada
|
||||
(nested-tabs/accordion, off-canvas, progress-tracker), tudo dependente de `elementor-pro`.
|
||||
|
||||
**`catalog-woo.php` — 92 linhas, 5 widgets WooCommerce** (existência confirmada, conteúdo NÃO
|
||||
lido em detalhe por instrução de âmbito). Mesma forma de array, `tier'=>'woo'`,
|
||||
`requires'=>'woocommerce'`. Chaves: `woocommerce-products`, `wc-add-to-cart`,
|
||||
`woocommerce-cart`, `woocommerce-checkout-page`, `woocommerce-menu-cart` — cobrem grid de
|
||||
produtos, botão de compra, e as páginas completas de carrinho/checkout como widgets
|
||||
embebíveis, mais um mini-carrinho para menu/header.
|
||||
|
||||
### 4.6 `EMCP_Tools_Block_Tree` — `includes/class-block-tree.php`
|
||||
|
||||
Transformações puras e sem estado sobre `parse_blocks()`/`serialize_blocks()` (funções core
|
||||
do WordPress). Blocos são endereçados por um PATH de índices (array de ints):
|
||||
`[0]` = primeiro bloco top-level (após remover blocos separadores em branco), `[2,1]` =
|
||||
`innerBlocks[1]` do bloco top-level de índice 2. **Todos os métodos de mutação devolvem uma
|
||||
ÁRVORE NOVA; nenhum muta in-place.**
|
||||
|
||||
- `from_markup()`/`to_markup()` — wrappers de parse/serialize (blocos unidos por linha em
|
||||
branco); `strip_separators()` remove blocos top-level de separador (blockName `null` +
|
||||
HTML em branco).
|
||||
- `at($blocks,$path)` — resolve um path para um nó ou `null`.
|
||||
- `insert/replace/remove/duplicate/move` — as 5 mutações principais, todas construídas sobre
|
||||
dois primitivos de baixo nível: `edit_siblings()` (navega até ao array de irmãos que CONTÉM
|
||||
o nó do path, aplica um callback que recebe `(siblings, index)` e devolve o novo array de
|
||||
irmãos) e `edit_node()` (wrapper fino para editar o próprio nó).
|
||||
- **`move()` tem três guards de segurança explícitos** que valem a pena replicar tal-e-qual:
|
||||
1. Mover relativo a si próprio (`mode` before/after com `$from === $to`) é no-op.
|
||||
2. Rejeita um movimento cujo alvo está DENTRO da própria subárvore do nó movido — senão o
|
||||
nó seria removido e depois a inserção falharia (o path já não resolve), perdendo o bloco
|
||||
silenciosamente.
|
||||
3. **Correcção de deslocamento de índice:** `remove()` desloca cada irmão posterior sob o
|
||||
pai de `$from` uma posição para a esquerda. Quando o path-alvo passa pelo MESMO pai numa
|
||||
posição posterior à de `$from`, esse índice fica desactualizado — é decrementado antes de
|
||||
`insert()`, aplicando-se a QUALQUER modo (before/after entre irmãos OU inside um
|
||||
container posterior) a qualquer profundidade.
|
||||
- `summarize($blocks,$depth,$prefix)` — vista compacta com path por get-post-blocks:
|
||||
`{path, blockName, attributes, innerBlocksCount}`, com `innerBlocks` aninhado só até
|
||||
`$depth` (se dado).
|
||||
- **`inner_content_for()` — o internals mais delicado do ficheiro.** Reconstrói o array
|
||||
`innerContent` de um bloco container quando o número de `innerBlocks` muda, PRESERVANDO o
|
||||
HTML de wrapper do container. `innerContent` intercala chunks de string literal (o HTML do
|
||||
wrapper) com placeholders `null` (um por bloco filho, consumidos por ordem por
|
||||
`serialize_block()`). Dois caminhos: (a) o container já tinha filhos (havia `null`s) →
|
||||
mantém o chunk antes do primeiro `null` e depois do último, reemite um `null` por filho
|
||||
novo; (b) container estava vazio (sem `null`s) → "descasca" a sequência final de tags de
|
||||
fecho via regex (`/((?:\s*<\/[a-zA-Z][a-zA-Z0-9]*>)+\s*)$/`) para que os filhos inseridos
|
||||
fiquem DENTRO do wrapper em vez de depois dele.
|
||||
|
||||
---
|
||||
|
||||
## 5. Blueprint para réplica
|
||||
|
||||
**Copiar quase 1:1 (alto valor, baixo risco de reescrever mal):**
|
||||
|
||||
- **`EMCP_Tools_Atomic_Props` inteiro.** É a peça mais valiosa deste documento. Reescrever do
|
||||
zero equivaleria a reproduzir ~2 anos de bugs de produção já corrigidos e documentados nas
|
||||
próprias issues citadas no código (#36, #56, #74, #97, #101, #102, #111). Atenção especial
|
||||
a três decisões de design: (1) `coerce_tree()` corre sobre a ÁRVORE INTEIRA no save, não só
|
||||
o elemento tocado — porque o Elementor valida a página inteira de uma vez; (2)
|
||||
`apply_prop_aliases()` corre ANTES da validação, nunca depois — porque o parser de props do
|
||||
Elementor descarta silenciosamente chaves não reconhecidas em vez de as rejeitar; (3)
|
||||
`is_atomic_supported()`/`is_container_supported()` NÃO se baseiam em `version_compare()`
|
||||
mas em introspecção de tipos de elemento realmente registados — o Elementor já enviou
|
||||
atomic como experiment opt-in em versões que ainda reportam `ELEMENTOR_VERSION` 3.x.
|
||||
- **`EMCP_Tools_Atomic_Widget_Map`** — pequeno (≈200 linhas) mas denso em armadilhas
|
||||
específicas do Elementor 4.0+ (chave `paragraph` vs `text`; `source` string vs shape;
|
||||
alt só funciona por `url`, para attachment vai para post meta). Copiar incluindo os
|
||||
comentários com o número de issue — são a única documentação que existe destas armadilhas.
|
||||
- **`EMCP_Tools_Block_Tree`** — ~330 linhas, código puro sem qualquer dependência Elementor.
|
||||
A lógica de `inner_content_for()` e os três guards de segurança de `move()`
|
||||
(auto-referência, alvo-dentro-da-subárvore, correcção de índice) representam bugs subtis já
|
||||
resolvidos; portar tal-e-qual evita reintroduzir os mesmos erros ao reescrever de raiz.
|
||||
|
||||
**Vale a pena simplificar:**
|
||||
|
||||
- **`EMCP_Tools_Atomic_Styles`** — a estrutura de "classe de estilo local" (variants por
|
||||
breakpoint+state, ID mintado com o `element_id` embutido) é ditada directamente pelo
|
||||
formato nativo `styles` do Elementor 4.0+, por isso tem de ser replicada fielmente SE a
|
||||
réplica quiser gerar CSS local por elemento — mas se só forem necessários estilos simples
|
||||
sem responsividade/estados, pode simplificar-se para um único variant fixo
|
||||
(`desktop`/`null`) e cortar a generalização de breakpoint/state.
|
||||
- **As Gutenberg abilities (a classe de abilities em si, não `Block_Tree`)** — muito mais
|
||||
simples de reescrever de raiz do que as atomic, porque não há `$$type` nem dependência
|
||||
Elementor nenhuma; a única peça deste grupo que vale a pena copiar exactamente é
|
||||
`EMCP_Tools_Block_Tree`, pelas razões acima.
|
||||
|
||||
**Vale a pena deixar de fora:**
|
||||
|
||||
- **`EMCP_Tools_Widget_Loader`** — 100% Pro-gated e específico ao mecanismo Sandbox de
|
||||
widgets gerados por IA (doc 06); sem relação directa com atomic/Gutenberg. Só relevante se
|
||||
a réplica também for construir um "widget builder" próprio a partir de código PHP gerado.
|
||||
- **Os tiers Pro/Woo do widget catalog** (`catalog-pro.php` 1049 linhas, `catalog-woo.php` 92
|
||||
linhas) — dados estáticos de descrição de widgets de terceiros que só fazem sentido se a
|
||||
réplica também for suportar Elementor Pro/WooCommerce como dependência opcional; a
|
||||
ESTRUTURA de dados (idêntica à de `catalog-free.php`) é trivial de reproduzir, o valor real
|
||||
está no CONTEÚDO (descrições/enums correctos por widget), que teria de ser levantado
|
||||
widget a widget contra a documentação oficial Elementor Pro/WooCommerce — não vale a pena
|
||||
tentar adivinhar a partir dos nomes de chave.
|
||||
|
||||
**Riscos/gotchas não óbvios encontrados no código (citações directas, valem ouro):**
|
||||
|
||||
1. *"`insert_element()` mutates `$page_data` by reference and returns a bool; save the
|
||||
modified `$page_data`, never the bool (issue #36)."* — padrão repetido em quase todos os
|
||||
`execute_*` callbacks das duas classes atomic; um erro comum e fácil seria gravar o valor
|
||||
de retorno booleano em vez da estrutura mutada.
|
||||
2. *"Elementor's Props_Parser SILENTLY DISCARDS keys it does not recognise and still reports
|
||||
the result as valid"* (issue #102) — motivo estrutural pelo qual `apply_prop_aliases()`
|
||||
tem de correr ANTES da validação, nunca depois.
|
||||
3. *"The `e-paragraph` content prop is named `paragraph` (Html_V3), not `text`. Writing `text`
|
||||
silently dropped the content (issue #56)."*
|
||||
4. *"There is no `background-color` style prop, so writing one is silently discarded"* /
|
||||
*"Elementor has no per-side `padding-block-start` style prop, so writing those
|
||||
individually is silently discarded on save"* — classe inteira de bugs "escreveu mas não
|
||||
aconteceu nada, sem erro" nas style props do v4; qualquer réplica precisa de mapear
|
||||
explicitamente cada shorthand em vez de assumir que props CSS planas funcionam.
|
||||
5. *"The `e-svg` widget's `svg` prop is a distinct `svg-src` type — NOT the `image`/`image-src`
|
||||
type used by `e-image`"* e *"`e-youtube`'s video prop is `source`, a plain string (union),
|
||||
NOT the video-src shape the self-hosted video widget uses"* — dois pares de widgets com
|
||||
nomes de prop idênticos (`source`, formatos tipo "src") mas shapes incompatíveis;
|
||||
confundir os dois quebra o widget silenciosamente ou faz o Elementor rejeitar o elemento
|
||||
inteiro.
|
||||
6. Detecção de suporte atomic/container **não é por número de versão** — ver §4.1, último
|
||||
parágrafo. Uma implicação prática: nem sequer basta verificar a feature flag do editor
|
||||
(`e_opt_in_v4_page`), porque é uma experiment SEPARADA de `e_atomic_elements` — um site
|
||||
pode ter a primeira ligada e a segunda desligada, escrever "com sucesso", e o
|
||||
`Document::save()` sanitizar/remover os elementos atómicos silenciosamente.
|
||||
7. **Gotcha nosso, não assinalado como tal no código:** a doc-comment `// Detect version
|
||||
(always registers, even on < 4.0)` em `class-atomic-layout-abilities.php` está
|
||||
desactualizada/incorrecta face ao guard clause real de `register()` — ver §2 para o
|
||||
detalhe. Numa réplica, registar `detect-elementor-version` INCONDICIONALMENTE (fora de
|
||||
qualquer guard de suporte atomic) resolve a inconsistência e torna a tool útil
|
||||
precisamente no cenário em que mais falta faz.
|
||||
8. *"`wp_update_post()` runs `wp_unslash()` on the data, and block serialization emits
|
||||
backslash escapes (&, \", \\ …) in attributes — so the markup MUST be slashed here or those
|
||||
escapes get stripped and the block corrupts."* — em `save_tree()` das Gutenberg abilities;
|
||||
um erro fácil de introduzir ao reescrever a persistência de blocos sem este detalhe.
|
||||
9. Issue #97 (`remap_local_classes`): duplicar um elemento atomic sem re-mintar as suas
|
||||
classes de estilo locais faz com que o duplicado e o original PARTILHEM a mesma classe,
|
||||
causando "style bleed" entre os dois e entradas duplicadas no popover "Style Origin" do
|
||||
editor Elementor — um bug de UX subtil que só aparece depois de duplicar e depois estilizar
|
||||
um dos dois separadamente.
|
||||
|
||||
---
|
||||
|
||||
## Fonte
|
||||
|
||||
Leitura directa (19-08-2026) de: `includes/abilities/class-atomic-widget-abilities.php`,
|
||||
`includes/abilities/class-atomic-layout-abilities.php`,
|
||||
`includes/abilities/class-gutenberg-abilities.php`, `includes/class-atomic-props.php` (973
|
||||
linhas, completo), `includes/class-atomic-widget-map.php` (completo),
|
||||
`includes/class-atomic-styles.php` (completo), `includes/class-widget-loader.php` (completo),
|
||||
`includes/widgets/class-widget-catalog.php` (completo),
|
||||
`includes/widgets/catalog-free.php` (680 linhas, completo), `includes/class-block-tree.php`
|
||||
(completo); existência + estrutura de chaves top-level (sem leitura de `params` internos) de
|
||||
`includes/widgets/catalog-pro.php` (1049 linhas, 30 widgets) e
|
||||
`includes/widgets/catalog-woo.php` (92 linhas, 5 widgets). Cruzado com
|
||||
`includes/abilities/class-ability-registrar.php` (já lido em sessão anterior, ver
|
||||
`docs/00-ARQUITECTURA.md`) para as condições exactas de gating de cada classe.
|
||||
@@ -0,0 +1,494 @@
|
||||
# 03 — WordPress core (conteúdo/media/settings/plugins/temas/users/menus) e integrações de temas/frameworks de blocos
|
||||
|
||||
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
|
||||
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
|
||||
19-08-2026. Ver `docs/00-ARQUITECTURA.md` para o contrato `emcp_tools_register_ability()` e o
|
||||
padrão de arranque; este documento assume esse contexto e não o repete.
|
||||
|
||||
Este grupo cobre **duas famílias distintas** de abilities:
|
||||
|
||||
1. **WordPress core puro** (content, media, settings, plugins, themes, users, nav menus) — sempre
|
||||
activas, sem dependência de Elementor nem de nenhum framework de terceiros.
|
||||
2. **Integrações de temas/frameworks de blocos** (Astra, Spectra, Kadence, Kadence Blocks) — o
|
||||
padrão "Themes tab" de dois dispatchers (`<id>-read`/`<id>-write`) por integração, cada uma
|
||||
condicional à presença do tema/plugin correspondente. Free tem 5 integrações concretas
|
||||
(Active Theme + Astra + Spectra + Kadence + Kadence Blocks); GeneratePress/GenerateBlocks/
|
||||
Blocksy são referenciadas no registrar mas **não existem como ficheiros no build Free**
|
||||
(ver §16).
|
||||
|
||||
## 1. `EMCP_Tools_Content_Abilities` — `includes/abilities/class-content-abilities.php`
|
||||
|
||||
Condição de registo: **sempre-on** (`register_groups()` linha ~158, sem guarda). Deliberadamente
|
||||
agnóstico ao Elementor — opera sobre `post_content` (HTML clássico ou markup de blocos Gutenberg)
|
||||
e nunca toca em `_elementor_data`.
|
||||
|
||||
8 tools. `check_read_permission`=`edit_posts`; `check_create_permission`=`edit_posts` (mesmo cap
|
||||
que ler — mimetiza o core WP, onde publicar é gated à parte); `check_edit_permission`=`edit_posts`
|
||||
+ `edit_post` por-post quando há `post_id`; `check_delete_permission`=`delete_posts` +
|
||||
`delete_post` por-post.
|
||||
|
||||
| Tool | Input (resumo) | O que faz | Permission | RO/Destr. |
|
||||
|---|---|---|---|---|
|
||||
| `list-post-types` | `public_only?:bool` (default true) | Lista post types registados (nome, label, hierárquico, `supports`, taxonomias); filtra internos (`revision`, `nav_menu_item`, `wp_template`, etc.) quando `public_only`. | `check_read_permission` | RO |
|
||||
| `list-taxonomies` | `post_type?`, `include_terms?:bool`, `terms_limit?:int` (≤500, default 100) | Lista taxonomias (opcionalmente filtradas por post type), com termos embutidos opcionais. | `check_read_permission` | RO |
|
||||
| `create-post` | `post_type?`(default post), `title`, `content`, `excerpt`, `status?`(enum draft/publish/pending/private/future), `slug?`, `author?`, `date?`, `parent?`, `menu_order?`, `comment_status?`, `terms?:{tax:ids[]}`, `meta?:{}`, `featured_image?:{id\|url}\|null` | `wp_insert_post()`. Recusa post types internos/inexistentes; `publish` exige `publish_posts`; `author` diferente do actual exige `edit_others_posts`; meta protegida (`_`-prefix ou `is_protected_meta`) é sempre recusada salvo allowlist via filtro `emcp_tools_content_allowed_protected_meta`. `featured_image.url` faz sideload via `EMCP_Tools_Url_Guard::safe_download()` (SSRF-guarded, bloqueia hosts privados/loopback/metadata cloud e revalida cada redirect). Regista change (`EMCP_Tools_Change_Recorder::record_post_create`). | `check_create_permission` | write, não-destr. |
|
||||
| `get-post` | `post_id` | Serialização completa: title/slug/status/content/excerpt/datas/parent/menu_order/comment_status/permalink/edit_link/author/terms/meta(filtrada)/featured_image/`is_elementor` (flag `_elementor_edit_mode==='builder'`). | `check_read_permission` | RO |
|
||||
| `update-post` | `post_id`, campos parciais iguais ao create + `terms_mode?`(replace/append) | Update parcial via `wp_update_post()`. Captura before-image (campos + meta + terms) para rollback via `Change_Recorder::record_post_fields`. Se o slug muda num post publicado, captura o URL antigo e sugere um redirect (`with_redirect_suggestion` → `EMCP_Tools_Redirect_Abilities::push_suggestion`, só quando `EMCP_Tools_Redirect_Module::is_enabled()`). | `check_edit_permission` | write, não-destr. |
|
||||
| `delete-post` | `post_id`, `force?:bool` | `force=false` (default): `wp_trash_post()`. `force=true`: snapshot completo (`Change_Recorder::snapshot_post`) antes de `wp_delete_post($id,true)` (reversível). Ambos os caminhos sugerem redirect para o URL morto. | `check_delete_permission` | write, **destrutivo** |
|
||||
| `list-posts` | `post_type?`(str\|array), `status?`(str\|array), `search?`, `taxonomy?:{tax:terms[]}`(AND), `author?`, `parent?`, `per_page?`(≤100), `page?`, `orderby?`, `order?` | `WP_Query` paginado, linhas compactas (sem body de conteúdo — usar `get-post` para isso). | `check_read_permission` | RO |
|
||||
| `set-post-terms` | `post_id`, `taxonomy`, `terms:(int\|string)[]`, `mode?`(replace/append/remove), `create_missing?:bool`(default true) | `wp_set_object_terms`/`wp_remove_object_terms`. Quando `create_missing=false`, resolve nomes para IDs existentes via `get_term_by`(name→slug) e descarta os que não existem (não cria). | `check_edit_permission` | write, não-destr. |
|
||||
|
||||
**Detalhe de implementação notável:** `apply_write_extras()` é o método partilhado usado por
|
||||
`create-post`/`update-post` para aplicar `terms`/`meta`/`featured_image` — inclui o comentário
|
||||
explícito no código sobre porquê carrega `wp-admin/includes/{file,media,image}.php` on-demand:
|
||||
essas funções (`media_handle_sideload`) não estão carregadas em pedidos REST/WP-CLI (que é onde o
|
||||
servidor MCP corre).
|
||||
|
||||
## 2. `EMCP_Tools_Media_Library_Abilities` — `includes/abilities/class-media-library-abilities.php`
|
||||
|
||||
Condição: **sempre-on** (linha ~143). Nasceu para preencher a lacuna que as tools de stock-image
|
||||
não cobrem: encontrar as fotos **próprias** do cliente já na Media Library (issue #25 do repo
|
||||
upstream). Recebe `EMCP_Tools_Data` no construtor (data-access layer partilhada — mesma classe
|
||||
usada por várias famílias de abilities de conteúdo Elementor, não específica de media).
|
||||
|
||||
5 tools.
|
||||
|
||||
| Tool | Input (resumo) | O que faz | Permission | RO/Destr. |
|
||||
|---|---|---|---|---|
|
||||
| `list-media` | `search?`, `mime_type?`(default `image`; aceita "any"), `page?`, `per_page?`(≤100), `orderby?`(date/title), `order?` | `WP_Query` sobre `attachment`. O `search` do WP_Query cobre title/caption/description mas **não** alt text (vive em postmeta) — por isso resolve IDs por texto e por alt em duas queries `ids`-only e faz a união via `post__in`, sem filtros globais de query. | `check_read_permission` (`edit_posts`) | RO |
|
||||
| `get-media` | `id` | Detalhe completo de 1 attachment: todos os tamanhos de imagem registados (url+dimensões via `wp_get_attachment_image_src` por tamanho), mime, filesize, alt, caption, descrição, autor, `post_parent`, metadata bruta. | `check_read_permission` | RO |
|
||||
| `upload-media` | `filename`, `data`(base64, aceita prefixo `data:...;base64,`), `alt?`, `title?`, `caption?`, `description?`, `post_id?`, `convert_webp?:bool` | **Companheira de sideload-image**: sideload-image busca um URL que o SERVIDOR alcança; `upload-media` recebe bytes RAW do CLIENTE (útil quando o utilizador quer enviar um ficheiro do seu próprio computador). Decodifica base64 (limite `UPLOAD_MAX_BYTES=32MB`, filtrável via `emcp_tools_upload_media_max_bytes`), escreve para `wp_tempnam()`, valida tipo com `wp_check_filetype_and_ext()` contra `get_allowed_mime_types()` (bloqueia executáveis mesmo que um filtro afrouxe depois), entrega a `media_handle_sideload()`. Suporta `convert_webp:false` via filtro `emcp_tools_optimize_attachment` (idêntico a sideload-image). | `check_upload_permission` (`upload_files`) | write, não-destr. |
|
||||
| `update-media` | `id`, `title?`, `alt?`, `caption?`, `description?` | Update parcial. `description` mapeia para `post_content` e **não** é `sanitize_text_field` (permitiria HTML legítimo — `wp_update_post` já aplica `wp_filter_post_kses` para quem não tem `unfiltered_html`). Captura before-image para rollback. | `check_edit_permission` (por-attachment) | write, não-destr. |
|
||||
| `delete-media` | `id`, `confirm:true`(obrigatório), `force?:bool` | **Destrutivo e efectivamente permanente**: WordPress ignora a Trash para media a menos que `MEDIA_TRASH` esteja definido — o próprio schema avisa disto na descrição. Snapshot completo antes de apagar (post + meta + cópia trashed de todos os ficheiros) via `Change_Recorder::snapshot_attachment`, só quando não vai para trash. | `check_delete_permission` (por-attachment) | write, **destrutivo** |
|
||||
|
||||
## 3. `EMCP_Tools_Settings_Abilities` — `includes/abilities/class-settings-abilities.php`
|
||||
|
||||
Condição: **sempre-on** (linha ~224). Nome deliberadamente distinto de `EMCP_Tools_Settings_Validator`
|
||||
(que valida settings de widgets Elementor — classe não relacionada).
|
||||
|
||||
**Decisão de design central:** NÃO expõe `get_option`/`update_option` arbitrário. Só a
|
||||
**allowlist tipada e curada** em `allowlist()` — 32 chaves cobrindo os 6 ecrãs de Settings do core
|
||||
(General/Reading/Writing/Discussion/Media/Permalinks). Cada entrada tem `{group, label, type
|
||||
(string|int|bool|enum), writable, options?, min?, max?, pattern?}`. Notavelmente **ausentes** da
|
||||
allowlist: `siteurl`/`home` (lock-out do site), `users_can_register`/`default_role` (escalada de
|
||||
registo). `admin_email` está presente mas `writable=false` (só leitura).
|
||||
|
||||
2 tools, ambas `check_manage_permission` = `manage_options`.
|
||||
|
||||
| Tool | Input | O que faz | RO/Destr. |
|
||||
|---|---|---|---|
|
||||
| `get-settings` | `group?`(enum dos 6 ecrãs), `keys?:string[]` | Sem args devolve as 32; filtra por grupo/chaves. Cada linha inclui `value` (coagido ao tipo declarado), `writable`, e `options[]` quando é enum — **duplica como discovery** para `update-settings`. | RO |
|
||||
| `update-settings` | `settings:{key:value}` | Escreve só chaves allowlisted+writable; tudo o resto (chave desconhecida, read-only, valor inválido) cai em `skipped[]` com motivo — **uma chave má nunca aborta o batch**. Coerção por tipo com clamp min/max (int), regex `pattern` (string, ex. `permalink_structure`), validação de `enum`. Se qualquer `permalink_structure`/`category_base`/`tag_base` mudar, chama `flush_rewrite_rules(false)` automaticamente e reporta `rewrite_flushed`. Regista before-map para rollback. | write, não-destr. (idempotente) |
|
||||
|
||||
## 4. `EMCP_Tools_Plugin_Abilities` — `includes/abilities/class-plugin-abilities.php`
|
||||
|
||||
Condição: **sempre-on** (linha ~229). 7 tools: 2 leitura (sempre activas) + 5 mutação
|
||||
(instalação/activação/desactivação/update/delete), todas construídas sobre APIs core (`Plugin_Upgrader`,
|
||||
`plugins_api`, `activate_plugin`/`deactivate_plugins`/`delete_plugins`) e guardadas por
|
||||
`EMCP_Tools_Package_Guard` (classe partilhada com theme-abilities — ver comentário do ficheiro:
|
||||
"as 5 tools de mutação vêm desligadas por omissão, o admin liga-as na tab Tools").
|
||||
|
||||
| Tool | Input | O que faz | Permission | RO/Destr. |
|
||||
|---|---|---|---|---|
|
||||
| `list-plugins` | `status?`(all/active/inactive) | `get_plugins()` + `get_site_transient('update_plugins')` para flag de update disponível; inclui `is_protected` (via `Package_Guard::is_protected_plugin`). | `activate_plugins` | RO |
|
||||
| `search-plugins` | `search`, `per_page?`(≤50) | `plugins_api('query_plugins', …)` — pesquisa no directório wordpress.org. | `install_plugins` | RO |
|
||||
| `install-plugin` | `slug`, `activate?:bool` | `plugins_api('plugin_information')` → `Plugin_Upgrader::install()`. **Fonte sempre wordpress.org — URLs arbitrários nunca são aceites** (explicitamente afirmado na descrição da tool). | `install_plugins` | write, não-destr. |
|
||||
| `activate-plugin` | `plugin`(file ou slug de pasta) | `activate_plugin()`. Resolve referência via `resolve_plugin_file()` (tenta como file exacto, depois como slug de pasta). | `activate_plugins` | write, idempotente |
|
||||
| `deactivate-plugin` | `plugin` | `deactivate_plugins()`. Recusa plugins protegidos (**EMCP Tools e Elementor nunca podem ser desactivados via MCP** — protege contra o agente cortar o próprio ramo em que está sentado). | `activate_plugins` | write, idempotente |
|
||||
| `update-plugin` | `plugin` | `wp_update_plugins()` refresca transient; se não há update pendente devolve `up_to_date:true` sem chamar o upgrader. Recusa plugins protegidos (mesma lista de deactivate). | `update_plugins` | write, não-destr. |
|
||||
| `delete-plugin` | `plugin` | `delete_plugins()`. Recusa protegidos E recusa qualquer plugin **activo** (obriga a desactivar primeiro). | `delete_plugins` | write, **destrutivo** |
|
||||
|
||||
## 5. `EMCP_Tools_Theme_Abilities` — `includes/abilities/class-theme-abilities.php`
|
||||
|
||||
Condição: **sempre-on** (linha ~233). Espelho exacto do padrão de plugin-abilities mas para temas
|
||||
(`Theme_Upgrader`, `themes_api`, `switch_theme`/`delete_theme`), mesmas 5+1 = 6 tools (sem
|
||||
`search`+`install`+`switch`+`update`+`delete`+`list` = 6, plugins tem 7 porque tem
|
||||
activate+deactivate separados; temas só têm `switch-theme`).
|
||||
|
||||
| Tool | Input | O que faz | Permission | RO/Destr. |
|
||||
|---|---|---|---|---|
|
||||
| `list-themes` | (nenhum) | `wp_get_themes()` + `get_site_transient('update_themes')`; inclui `parent` (para child themes) e `is_active`. | `switch_themes` | RO |
|
||||
| `search-themes` | `search`, `per_page?`(≤50) | `themes_api('query_themes', …)` sobre wordpress.org. | `install_themes` | RO |
|
||||
| `install-theme` | `slug`, `activate?:bool` | `themes_api('theme_information')` → `Theme_Upgrader::install()`. Fonte sempre wordpress.org. | `install_themes` | write, não-destr. |
|
||||
| `switch-theme` | `stylesheet` | `switch_theme()`. Recusa temas com `$theme->errors()` (load errors). | `switch_themes` | write, idempotente |
|
||||
| `update-theme` | `stylesheet` | `wp_update_themes()` refresca transient; `up_to_date:true` sem upgrade se nada pendente. | `update_themes` | write, não-destr. |
|
||||
| `delete-theme` | `stylesheet` | `delete_theme()`. Recusa o tema activo **e o parent do tema activo** (`Package_Guard::active_theme_stylesheets()`). | `delete_themes` | write, **destrutivo** |
|
||||
|
||||
## 6. `EMCP_Tools_User_Abilities` — `includes/abilities/class-user-abilities.php`
|
||||
|
||||
Condição: **sempre-on** (linha ~238). O comentário de cabeçalho do ficheiro resume a filosofia:
|
||||
"the security boundary is the design" — **sem tool de delete**, **sem tool de mudança de role**,
|
||||
password sempre auto-gerada e nunca devolvida.
|
||||
|
||||
4 tools. Guarda de privilégio central: `protected_caps()` = `{manage_options, promote_users,
|
||||
delete_users, edit_users, manage_network}`. `user_has_admin_caps($id)` verifica se um utilizador
|
||||
tem QUALQUER uma destas caps (via `user_can`) — se sim, é **intocável** por `update-user`.
|
||||
`role_has_admin_caps($role)` faz o mesmo a nível de role, para bloquear `create-user` de atribuir
|
||||
uma role admin-grade.
|
||||
|
||||
| Tool | Input | O que faz | Permission | RO/Destr. |
|
||||
|---|---|---|---|---|
|
||||
| `list-users` | `role?`, `search?`, `per_page?`(≤100), `page?`, `orderby?`(registered/display_name/ID), `order?` | `WP_User_Query`. Linhas compactas (id, username, display_name, email, roles, registered, post_count) — nunca password/auth. | `list_users` | RO |
|
||||
| `get-user` | `id` | Detalhe: username/email/display_name/first_name/last_name/nickname/url/description/roles/registered/post_count + flag `is_admin` (= `user_has_admin_caps`, sinaliza que `update-user` vai recusar). | `list_users` | RO |
|
||||
| `create-user` | `username`, `email`, `role?`(default `subscriber`), `first_name?`, `last_name?`, `display_name?`, `url?`, `description?` | Password gerada com `wp_generate_password(24, true, true)` (nunca devolvida). Envia email de "definir password" via `wp_send_new_user_notifications($id, 'user')`. Recusa role inexistente ou admin-grade. **Anti-enumeração:** erros `existing_user_login`/`existing_user_email` são normalizados para uma mensagem genérica ("that username or email is not available") para que a tool não sirva para enumerar contas existentes. | `create_users` | write, não-destr. |
|
||||
| `update-user` | `id`, `email?`, `first_name?`, `last_name?`, `display_name?`, `nickname?`, `url?`, `description?` | Recusa se o alvo tem admin-caps. Constrói o update **só** a partir dos campos permitidos — `role`/`password` **nunca são sequer lidos** do input, logo nunca podem mudar aqui (não é uma verificação em runtime, é ausência estrutural do campo). Captura before-image para rollback. | `edit_users` | write, não-destr. |
|
||||
|
||||
## 7. `EMCP_Tools_Nav_Menu_Abilities` — `includes/abilities/class-nav-menu-abilities.php`
|
||||
|
||||
Condição: **sempre-on** (linha ~243). Padrão de **dois dispatchers** (`menu-read`/`menu-write`),
|
||||
o mesmo padrão que as integrações de tema (§9-13) e as abilities ACF/SEO/forms (doc 08) usam:
|
||||
cada tool aceita `{operation, arguments}`; chamar sem `operation` devolve o catálogo de operações
|
||||
disponíveis (nome + descrição) — auto-descoberta sem precisar de schemas JSON per-operation
|
||||
separados. Ambos os dispatchers usam a mesma permissão: `edit_theme_options`.
|
||||
|
||||
**13 operações** no total:
|
||||
|
||||
| Operação | Modo | Argumentos | O que faz |
|
||||
|---|---|---|---|
|
||||
| `list-menus` | read | `{}` | Todos os menus: id, name, slug, item count, locations atribuídas. |
|
||||
| `get-menu` | read | `{menu: id\|slug\|name}` | Um menu + a sua árvore de items aninhada (`build_item_tree`, construída a partir da lista flat via `menu_item_parent`). |
|
||||
| `list-locations` | read | `{}` | Locations de menu registadas pelo tema + qual menu (se algum) está atribuído a cada uma. |
|
||||
| `render` | read | `{menu\|location, depth?, container?, container_class?, menu_class?, menu_id?}` | `wp_nav_menu(['echo'=>false])` → devolve HTML. Útil para embutir num header custom. |
|
||||
| `create-menu` | write | `{name}` | `wp_create_nav_menu()`. |
|
||||
| `rename-menu` | write | `{menu, name}` | `wp_update_nav_menu_object()`. |
|
||||
| `delete-menu` | write | `{menu}` | `wp_delete_nav_menu()`. |
|
||||
| `assign-location` | write | `{menu, location}` | `set_theme_mod('nav_menu_locations', …)`. Valida que a location está registada pelo tema. |
|
||||
| `unassign-location` | write | `{location}` | Remove a atribuição de uma location. |
|
||||
| `add-item` | write | `{menu, type, object_id?, object?, url?, title?, parent?, position?, target?, classes?, description?, xfn?}` | `wp_update_nav_menu_item()`. `resolve_item_type()` valida `type` (custom/page/post/CPT/category/taxonomy) contra objectos reais existentes — nunca cria um item apontando para um post/termo inexistente. Items custom exigem `url`+`title`. |
|
||||
| `update-item` | write | `{item, title?, url?, parent?, position?, target?, classes?, description?, xfn?}` | Update parcial que **preserva** todos os campos não especificados (`merge_existing_item()` lê o item actual via `wp_setup_nav_menu_item()` antes de mesclar as mudanças — evita a armadilha comum de `wp_update_nav_menu_item` de apagar campos omitidos). |
|
||||
| `delete-item` | write | `{item}` | `wp_delete_post($id, true)` (items de menu são posts `nav_menu_item`). |
|
||||
| `reorder-items` | write | `{menu, items: [{id, parent?, position}]}` | Reordena/reparenta múltiplos items numa chamada, preservando os restantes campos de cada um (reutiliza `merge_existing_item`). Ignora silenciosamente items inválidos/de outro menu/com parent inválido, e conta só os efectivamente actualizados. |
|
||||
|
||||
**Validações estruturais notáveis:** `validate_parent()` garante que um parent proposto é um item
|
||||
de menu real e pertence **ao mesmo menu** (não permite cruzar menus). `resolve_item_type()` faz a
|
||||
mesma validação de existência para `add-item` que `set-post-terms` faz para termos — nunca cria
|
||||
referências soltas.
|
||||
|
||||
## 8. `EMCP_Tools_Image_Resize_Abilities` — `includes/abilities/class-image-resize-abilities.php`
|
||||
|
||||
Condição: **condicional dupla** (registrar linha ~149) — só regista quando `class_exists(
|
||||
'EMCP_Tools_Image_Resize_Abilities' )` **E** `class_exists( 'EMCP_Tools_Image_Optimization_Module'
|
||||
)` **E** `EMCP_Tools_Image_Optimization_Module::module_is_active()`. Ou seja: depende do módulo
|
||||
"Image Optimization" (um dos 9 módulos opcionais documentados no doc 10) estar activo — reutiliza
|
||||
a maquinaria de backup+compressão+WebP desse módulo (`EMCP_Tools_Image_Resizer::resize()`, classe
|
||||
do módulo, não desta ability).
|
||||
|
||||
1 tool: `resize-media`. `attachment_id`, `width?`, `height?` (pelo menos um dos dois; escala
|
||||
mantendo aspect ratio), `crop?:bool` (hard-crop exacto width×height, requer ambos). Permissão:
|
||||
`edit_post($attachment_id)` se especificado, senão `upload_files`. O **ID do attachment e as suas
|
||||
URLs mantêm-se os mesmos** (resize in-place), o original é feito backup (reversível), e todos os
|
||||
sub-tamanhos + WebP são regenerados. Se o cap de dimensão máxima do módulo for menor que o alvo
|
||||
pedido, o cap aplica-se silenciosamente.
|
||||
|
||||
## 9. `EMCP_Tools_Theme_Integration` (abstract) — `includes/abilities/class-theme-integration.php`
|
||||
|
||||
**Serviço de suporte**, não regista abilities por si — é a **classe base abstracta** para todas as
|
||||
integrações de tema/framework (§10-13 + os Pro-only do §16). Implementa o padrão "dois
|
||||
dispatchers" de forma genérica e reutilizável.
|
||||
|
||||
Contrato abstracto que cada subclasse implementa: `id()` (usado para construir os nomes das tools
|
||||
`emcp-tools/<id>-read`/`-write`), `label()`, `is_available()` (gate de disponibilidade — tema
|
||||
activo ou plugin activo), `operations()` (o mapa `name => {mode, run, perm, desc}`).
|
||||
|
||||
A base fornece:
|
||||
- **`register()`** — regista os dois dispatchers via `emcp_tools_register_ability()`. Ambos usam
|
||||
o mesmo `dispatch_schema()` genérico (`{operation?, arguments?}`).
|
||||
- **`can_read()`/`can_write()`** — gate grosseiro **da tool** (`edit_theme_options` por omissão);
|
||||
cada subclasse pode sobrepor (Spectra e Kadence Blocks sobrepõem para `edit_posts`, porque
|
||||
constroem conteúdo, não opções de tema — ver §12/§13).
|
||||
- **`dispatch()`** — resolve a operação pelo nome; se `operation` vazio devolve o **catálogo de
|
||||
descoberta**; valida que o modo (read/write) bate; corre a **permissão específica da operação**
|
||||
(`$op['perm']`) — nunca confia só no gate grosseiro do tool, cada operação individual
|
||||
re-verifica. Um erro 404 `unknown_operation` ou 403 `forbidden` tem `status` no `WP_Error` data.
|
||||
- **`capabilities()`** — descritor normalizado (`supports_patterns`, `supports_preview`,
|
||||
`styles_model`: none/uniqueid/styles-object/attributes) que um agente lê para saber, sem
|
||||
hard-coding por-framework, se este pack tem uma rota de inserção "editor-válida" (patterns) ou
|
||||
preview, e como o pack se auto-estiliza. Default `{false, false, 'none'}`; cada subclasse
|
||||
sobrepõe.
|
||||
|
||||
**Blueprint chave:** este é o padrão a copiar quase 1:1 numa réplica própria — uma classe base
|
||||
abstracta de ~250 linhas que qualquer integração de tema/plugin de terceiros herda, ganhando
|
||||
discovery + dispatch + permission-per-operation de graça. Adicionar um framework novo (ex.
|
||||
GeneratePress) é só escrever a subclasse concreta com `operations()`.
|
||||
|
||||
## 10. `EMCP_Tools_Active_Theme_Integration` — `includes/abilities/class-active-theme-integration.php`
|
||||
|
||||
`id()`='theme'. `is_available()` = **sempre true** (funciona com qualquer tema activo — é o pack
|
||||
agnóstico-de-framework). Descrição do ficheiro: "Building pages reuses the Gutenberg/Elementor
|
||||
tools; this integration supplies the context the agent reasons over."
|
||||
|
||||
4 operações:
|
||||
|
||||
| Operação | Modo | Argumentos | O que faz |
|
||||
|---|---|---|---|
|
||||
| `get-theme-context` | read | `{}` | Identidade + capacidades do tema activo: stylesheet/parent, `is_child`, framework detectado (contra `KNOWN_FRAMEWORKS = {astra, kadence, generatepress, oceanwp, blocksy, neve, hello-elementor}`), `is_block_theme` (`wp_is_block_theme()`), nome/versão, `template_dir`, `theme supports` probed (`PROBED_SUPPORTS`: post-thumbnails, custom-logo, editor-styles, wp-block-styles, align-wide, responsive-embeds, custom-background, html5), menu locations registadas, `has_child` (`Child_Theme_Builder::child_exists()`). **"Call this first"** — é o ponto de entrada de contexto antes de qualquer outra operação de tema. |
|
||||
| `get-mods` | read | `{}` | Todos os `theme_mods` do tema activo (estado do customizer). |
|
||||
| `set-mods` | write | `{values: {key: value}}` | `set_theme_mod()` por chave, salvo `REFUSED_MODS = {nav_menu_locations, sidebars_widgets, custom_css_post_id}` — mods estruturais recusados (esses domínios têm as suas próprias tools: nav-menu, widgets não têm tool própria, custom CSS é módulo separado). |
|
||||
| `create-child-theme` | write, `perm`=`can_manage_theme` (`switch_themes`+`edit_theme_options`) | `{confirm:true}`(obrigatório) | Delega para `EMCP_Tools_Child_Theme_Builder::create()` (§14). Exige `confirm:true` porque muda o tema activo E liga escrita de ficheiros. |
|
||||
|
||||
## 11. `EMCP_Tools_Astra_Integration` — `includes/abilities/class-astra-integration.php`
|
||||
|
||||
`id()`='astra'. `is_available()` = `'astra' === get_template()`. Lê/escreve a única option
|
||||
`astra-settings` sobre uma **allowlist curada de 17 chaves** (`ALLOWLIST` const), agrupadas em
|
||||
`colors`/`typography`/`layout`/`header-footer` — a mesma forma genérica get/update que
|
||||
`EMCP_Tools_Settings_Abilities` usa para o core WordPress (§3), aplicada ao option próprio do
|
||||
Astra. Allowlist explicitamente marcada como "verified against Astra 4.13.4 on the dev site".
|
||||
|
||||
2 operações: `get-settings` (`{group?, keys?}`) e `update-settings` (`{values:{key:value}}`,
|
||||
chaves não-allowlisted em `skipped[]`). `read_value()` prefere o resolver nativo `astra_get_option()`
|
||||
quando disponível (respeita o default do tema quando a option está ausente); senão cai no valor
|
||||
bruto da option. **Detalhe de invalidação de cache notável:** Astra só refresca o seu CSS dinâmico
|
||||
em cache no hook `customize_save_after`, **não** numa escrita simples de option — por isso
|
||||
`execute_update_settings()` chama explicitamente `astra_clear_all_assets_cache()` após qualquer
|
||||
update, senão as mudanças ficariam invisíveis até o próximo save do Customizer.
|
||||
|
||||
## 12. `EMCP_Tools_Spectra_Integration` — `includes/abilities/class-spectra-integration.php`
|
||||
|
||||
`id()`='spectra'. `is_available()` = `EMCP_Tools_Spectra_Catalog::is_active()`. Sobrepõe
|
||||
`can_read()`/`can_write()` para `edit_posts` (constrói **conteúdo**, não opções de tema).
|
||||
`capabilities()`: `{supports_patterns:false, supports_preview:false, styles_model:'attributes'}`.
|
||||
|
||||
3 operações — o padrão discover→inspect→act espelhando o catálogo de widgets Elementor:
|
||||
|
||||
| Operação | Modo | Argumentos | O que faz |
|
||||
|---|---|---|---|
|
||||
| `list-blocks` | read | `{category?, search?}` | Catálogo compacto (via `Spectra_Catalog::blocks_index()`). |
|
||||
| `get-block-schema` | read | `{name?\|names?[], full?}` | Atributos reais + defaults (`Spectra_Catalog::real_attributes()`) + markup de exemplo gerado. Reporta também `shared_attributes` (ver §12.1) e um `note` quando trunca à `DEFAULT_CAP=30`. |
|
||||
| `add-block` | write | `{post_id, block, attributes?, position?}` | Constrói o array de bloco parseado (`build_block()`), insere-o via `EMCP_Tools_Block_Tree::insert()` (serviço partilhado com Gutenberg — doc 02) na árvore existente do post, serializa de volta com `serialize_block()`, grava com `wp_update_post`. Gera `block_id` via `EMCP_Tools_Id_Generator::generate()`. |
|
||||
|
||||
### 12.1 `EMCP_Tools_Spectra_Catalog` — `includes/blocks-catalog/class-spectra-catalog.php`
|
||||
|
||||
Serviço de suporte (não regista abilities). Nada é adivinhado — as duas metades vêm do próprio
|
||||
Spectra:
|
||||
- **Lista de blocos** — `UAGB_Block_Module::get_blocks_info()` (a própria API pública do Spectra).
|
||||
- **Atributos por bloco** — lidos directamente do ficheiro fonte do Spectra
|
||||
`includes/blocks/<slug>/attributes.php` (via `require`, não introspecção de registry) — porque
|
||||
Spectra não regista todos os atributos no `WP_Block_Type_Registry` core.
|
||||
|
||||
`STRUCTURE` const documenta hints estruturais que **não** podem vir de `attributes.php`: quais
|
||||
blocos precisam de innerBlocks template (`uagb/buttons`→`uagb/buttons-child`, etc., 8 pares
|
||||
documentados) e quais são dinâmicos/server-rendered (`post-grid`, `post-carousel`, `google-map`,
|
||||
`forms`, etc. — `add-block` emite markup self-closing para estes).
|
||||
|
||||
**`SHARED_ATTRS`** é o achado mais interessante desta classe: documenta atributos nativos e reais
|
||||
do `uagb/container` que são registados via um helper partilhado (`UAGB_Block_Helper`) em vez de
|
||||
chaves literais em `attributes.php` — por isso **não aparecem** em `real_attributes()`, mas
|
||||
existem e são editáveis (background image/video com overlay, border-radius por canto, box-shadow).
|
||||
O comentário no código é explícito: "so they DO NOT appear in real_attributes()/get-block-schema,
|
||||
yet they are real, editable attributes an agent should use instead of a core/html workaround" —
|
||||
inclui até a gotcha exacta de que o overlay em gradiente vem de `gradientValue`, **não** de
|
||||
`gradientOverlayColor1/2` como seria intuitivo.
|
||||
|
||||
## 13. `EMCP_Tools_Kadence_Integration` (theme settings) — `includes/abilities/class-kadence-integration.php`
|
||||
|
||||
`id()`='kadence'. `is_available()` = `'kadence' === get_template()`. Lê/escreve `theme_mods` do
|
||||
Kadence (não uma option única como Astra) sobre uma **allowlist de 16 chaves** agrupadas em
|
||||
`palette`/`colors`/`typography`/`layout`/`buttons`/`header-footer`. Cada entrada da allowlist
|
||||
carrega um `shape` (string descritiva da forma do objecto esperado) — porque, ao contrário de
|
||||
Astra, os valores de Kadence são **objectos estruturados**, não escalares (ex.
|
||||
`link_color = { highlight: "palette1", "highlight-alt": "palette2", style: "standard" }`), e
|
||||
referenciam uma **paleta global de 9 slots** (`palette1`..`palette9`) definida em `global_palette`.
|
||||
|
||||
2 operações: `get-settings`/`update-settings`, mesma forma que Astra. `read_value()` prefere
|
||||
`\Kadence\kadence()->option($key)` (resolver nativo com theme_mod+default embutido). **Sem
|
||||
invalidação de cache explícita** — comentário do ficheiro nota que "Kadence renders dynamic CSS
|
||||
inline per request, so there is no cache to invalidate" (contraste directo com o gotcha do Astra
|
||||
em §11).
|
||||
|
||||
## 14. `EMCP_Tools_Kadence_Blocks_Integration` — `includes/abilities/class-kadence-blocks-integration.php`
|
||||
|
||||
`id()`='kadence-blocks'. `is_available()` = `EMCP_Tools_Kadence_Blocks_Catalog::is_active()`.
|
||||
Independente do tema activo (tal como Spectra — Kadence Blocks é um plugin, não amarrado ao tema
|
||||
Kadence). `can_read()`/`can_write()` = `edit_posts`. `capabilities()`:
|
||||
`{supports_patterns:true, supports_preview:true, styles_model:'uniqueid'}`.
|
||||
|
||||
**5 operações** — a mais rica das integrações de tema, porque acrescenta acesso à Kadence
|
||||
Prebuilt Library (patterns prontos) por cima do padrão discover→inspect→act:
|
||||
|
||||
| Operação | Modo | Argumentos | O que faz |
|
||||
|---|---|---|---|
|
||||
| `list-blocks` | read | `{category?, search?}` | Catálogo curado dos 32 blocos top-level (via `Kadence_Blocks_Catalog`). |
|
||||
| `get-block-schema` | read | `{name?\|names?[], full?}` | Atributos reais do `WP_Block_Type_Registry` (Kadence, ao contrário do Spectra, regista schemas completos com defaults no core registry — não precisa de ler ficheiro fonte). Inclui `inner_blocks` hint para containers, `content_attributes` para campos RichText/HTML-source, e um `editor_note` fixo (ver abaixo). |
|
||||
| `add-block` | write | `{post_id, block, attributes?, position?}` | Gera `uniqueID` (chave CSS por-bloco do Kadence), escafolda inner blocks de container conforme `STRUCTURE`, **renderiza campos RichText directamente no HTML guardado** (não no JSON de atributos — ver `render_source_fields()` abaixo). |
|
||||
| `list-patterns` | read | `{category?, search?, include_pro?, categories?:bool}` | Lista patterns da Kadence Design Library (cache local em `uploads/kadence_blocks_library/`). `categories:true` devolve só os nomes de categoria. |
|
||||
| `insert-pattern` | write | `{post_id, pattern, position?, localize_images?:bool}` | Insere markup **canónico** de um pattern (busca via `Kadence_Blocks_Prebuilt_Library_REST_Controller::get_pattern_content` — o próprio controller REST do Kadence, chamado internamente sem HTTP). `localize_images:true` transfere as imagens do pattern para a Media Library via o próprio `process_pattern` do Kadence (requer `upload_files`). |
|
||||
|
||||
**Nota de robustez notável em `add-block`:** o output devolve sempre um `note` explícito — "Kadence
|
||||
blocks use a static JS save(), so a headlessly-inserted block renders correctly on the front end
|
||||
but the block editor may show 'Attempt recovery' on this block — one click regenerates valid
|
||||
markup and preserves the content." Isto é honestidade de design: blocos Kadence construídos à mão
|
||||
(não via pattern) não batem byte-a-byte com o `save()` estático do JS do bloco, então o editor
|
||||
Gutenberg vai reclamar (recuperável, sem perda de dados) — só o caminho `insert-pattern` (markup
|
||||
canónico do próprio Kadence) evita esse aviso.
|
||||
|
||||
**`render_source_fields()`** é o mecanismo mais complexo desta classe: para cada atributo que o
|
||||
registry marca como `source: html|rich-text` (ex. o `content` do Advanced Heading), renderiza o
|
||||
valor directamente em HTML dentro do bloco serializado usando o **selector real** registado por
|
||||
esse atributo (tag+classe), em vez de o guardar no JSON de `attrs` — porque é assim que WordPress
|
||||
lê esses valores de volta (parseados do HTML, não do JSON). Trata o caso especial "Advanced
|
||||
Heading" (`selector_element()`): a sua tag vem de `htmlTag` (h1-h6/p/div/span), e a classe CSS
|
||||
correcta é `kt-adv-heading{uniqueID}` — não o que o `selector` bruto do registry sugeriria.
|
||||
|
||||
### 14.1 `EMCP_Tools_Kadence_Blocks_Catalog` — `includes/blocks-catalog/class-kadence-blocks-catalog.php`
|
||||
|
||||
Serviço de suporte. Diferente do Spectra: ambas as metades (lista + atributos) vêm do
|
||||
`WP_Block_Type_Registry` **core** — Kadence regista schemas de atributos completos com defaults,
|
||||
não precisa de ler ficheiro fonte. `TOP_LEVEL` const lista os 32 slugs "placeable" (exclui blocos
|
||||
filho/estruturais como `column-child`, `singlebtn`, `listitem`, `table-row`, que são inseridos via
|
||||
`STRUCTURE`). `TITLES`/`CATEGORY`/`HIGHLIGHT`/`STRUCTURE` são curadoria manual (explicitamente
|
||||
"live-verified on Kadence Blocks 3.7.9" contra uma spec própria referenciada no comentário).
|
||||
|
||||
`STRUCTURE['kadence/rowlayout']` tem uma gotcha documentada no comentário: `colLayout` **tem** de
|
||||
ser não-vazio ("equal") ou o editor mostra o picker "Select Your Layout" do Kadence em vez das
|
||||
colunas — "the frontend renders regardless" mas a experiência de edição fica quebrada sem isto.
|
||||
|
||||
### 14.2 `EMCP_Tools_Kadence_Pattern_Library` — `includes/blocks-catalog/class-kadence-pattern-library.php`
|
||||
|
||||
Serviço de suporte. Lê o **cache local de metadados** (`uploads/kadence_blocks_library/*.json`,
|
||||
distingue o ficheiro de metadata do de preview-HTML pela presença de `slug`+`name` nas entradas) e
|
||||
delega a obtenção do markup real ao **controller REST do próprio Kadence**
|
||||
(`Kadence_Blocks_Prebuilt_Library_REST_Controller`), invocado **internamente** (constrói um
|
||||
`WP_REST_Request` e chama o método directamente — sem round-trip HTTP). Nota explícita no cabeçalho
|
||||
do ficheiro: se a cache local estiver vazia, o utilizador precisa de abrir a Kadence Design Library
|
||||
uma vez no editor de blocos para a popular — a tool `list-patterns` devolve esse aviso quando
|
||||
detecta catálogo vazio.
|
||||
|
||||
## 15. `EMCP_Tools_Child_Theme_Builder` — `includes/class-child-theme-builder.php`
|
||||
|
||||
Serviço de suporte, usado por `Active_Theme_Integration::execute_create_child_theme` (§10).
|
||||
**Deliberadamente conservador**, três garantias explícitas no comentário de cabeçalho:
|
||||
|
||||
1. Só cria um child do parent **actualmente activo** — nunca toca noutro tema.
|
||||
2. Recusa quando o tema activo **já é um child** (`is_active_a_child()`) — nunca cria um
|
||||
"neto" (grandchild).
|
||||
3. **Idempotente** — um child existente é apenas activado (`switch_theme`), nunca sobrescrito.
|
||||
|
||||
`create()`: cria a pasta (`wp_mkdir_p`), gera `style.css` mínimo (cabeçalho `Theme Name`/`Template`/
|
||||
`Version`/`Description`) e `functions.php` mínimo (enfileira o `style.css` do parent via
|
||||
`wp_enqueue_scripts`), depois `switch_theme($slug)`. Escreve via `$wp_filesystem->put_contents()`
|
||||
quando inicializado, senão `file_put_contents()` directo — o mesmo padrão dual usado pelas
|
||||
Filesystem tools (doc 07). Devolve `{child, parent, directory, activated, created}`.
|
||||
|
||||
**É o enabler estrutural para edição de ficheiros de tema pelo agente**: depois de criar o child,
|
||||
o agente edita `style.css`/`functions.php`/templates via as tools de Filesystem (ABSPATH-confined,
|
||||
gated por `edit_files`, com backup e audit-log — doc 07), nunca directamente aqui.
|
||||
|
||||
## 16. Pro-only confirmados ausentes: GeneratePress, GenerateBlocks, Blocksy
|
||||
|
||||
`class-ability-registrar.php` linhas 342-355 referencia 4 classes adicionais de integração de
|
||||
tema, seguindo o comentário explícito no código: *"GeneratePress + GenerateBlocks (Pro; classes
|
||||
only present when Pro loaded)"* e *"Blocksy (Pro): blocks + Companion extensions."*
|
||||
|
||||
```php
|
||||
if ( class_exists( 'EMCP_Tools_GeneratePress_Integration' ) ) { … }
|
||||
if ( class_exists( 'EMCP_Tools_GenerateBlocks_Integration' ) ) { … }
|
||||
if ( class_exists( 'EMCP_Tools_Blocksy_Blocks_Integration' ) ) { … }
|
||||
if ( class_exists( 'EMCP_Tools_Blocksy_Extensions_Integration' ) ) { … }
|
||||
```
|
||||
|
||||
**Confirmado por listagem directa do directório** `includes/abilities/` neste build Free (19-08-2026,
|
||||
48 ficheiros `class-*.php` listados): **nenhum ficheiro** `class-generatepress-integration.php`,
|
||||
`class-generateblocks-integration.php`, `class-blocksy-blocks-integration.php` ou
|
||||
`class-blocksy-extensions-integration.php` existe. As 4 classes são referenciadas apenas pelo
|
||||
`class_exists()` gate — em produção Free, este gate é sempre `false`; as 4 integrações só passam a
|
||||
existir (ficheiro + classe) quando o build **Pro** (`emcp-pro/`) está instalado e activo.
|
||||
|
||||
Cada uma seguiria, por analogia estrutural às 5 integrações Free já lidas (§10-14), o mesmo padrão
|
||||
herdado de `EMCP_Tools_Theme_Integration` (§9):
|
||||
|
||||
| Classe (Pro-only, código não acessível) | Gating no registrar | Padrão inferido por analogia (não confirmado) |
|
||||
|---|---|---|
|
||||
| `EMCP_Tools_GeneratePress_Integration` | `class_exists()` | `id()`='generatepress' provavelmente; `is_available()` provavelmente `'generatepress' === get_template()` — tema settings via GP Premium/GenerateBlocks options, seguindo o molde Astra/Kadence (§11/§13). |
|
||||
| `EMCP_Tools_GenerateBlocks_Integration` | `class_exists()` | Pack de blocos GenerateBlocks (catálogo + add-block), seguindo o molde Spectra/Kadence Blocks (§12/§14) — seria o "blocks/builder" companion do GeneratePress. |
|
||||
| `EMCP_Tools_Blocksy_Blocks_Integration` | `class_exists()` | Pack de blocos Blocksy (a Blocksy tem os seus próprios blocos Gutenberg nativos). |
|
||||
| `EMCP_Tools_Blocksy_Extensions_Integration` | `class_exists()` | Gestão de "Extensions" do Blocksy (o painel de extensões modulares do tema — ex. Content Blocks, Custom Fonts, Ecommerce, etc.), provavelmente um dispatcher settings-like análogo ao Astra. |
|
||||
|
||||
**Não inventar schemas nem comportamento além desta inferência estrutural** — sem acesso ao código
|
||||
fonte destas 4 classes, este é o limite do que se pode documentar com honestidade.
|
||||
|
||||
## Blueprint para réplica
|
||||
|
||||
**Copiar quase 1:1:**
|
||||
- **`EMCP_Tools_Theme_Integration` (abstract, §9)** — o padrão de 2 dispatchers + discovery +
|
||||
permission-per-operation é genérico, pequeno (~250 linhas), e escala bem para qualquer novo
|
||||
framework/plugin de terceiros. É o único ponto de extensão que vale a pena manter exactamente
|
||||
como está numa réplica.
|
||||
- **`EMCP_Tools_Settings_Abilities` (§3) e o padrão allowlist tipada** — a decisão de nunca expor
|
||||
`get_option`/`update_option` cru é a protecção certa; a allowlist com `{type, writable, min,
|
||||
max, pattern, options}` é suficientemente expressiva sem ser um mini-JSON-Schema paralelo.
|
||||
Reutilizar esta forma para settings de qualquer plugin de terceiros (Astra/Kadence já o fazem).
|
||||
- **Guarda de privilégio de `User_Abilities` (§6)** — `protected_caps()` + `user_has_admin_caps`/
|
||||
`role_has_admin_caps` é uma barreira de segurança simples e correcta (nenhuma tool de delete,
|
||||
role/password nunca lidos do input de update). Copiar tal e qual.
|
||||
- **`Package_Guard` (referenciado por Plugin/Theme abilities, §4/§5)** — lista de protecção
|
||||
(nunca desactivar/apagar/actualizar o próprio plugin nem o Elementor) é um padrão de
|
||||
auto-preservação que qualquer plugin MCP-serving precisa de replicar (senão o agente pode
|
||||
cortar o próprio ramo em que está sentado).
|
||||
- **`Child_Theme_Builder` (§15)** — as 3 garantias conservadoras (só o parent activo, nunca um
|
||||
grandchild, idempotente) são exactamente as invariantes certas para este tipo de operação.
|
||||
|
||||
**Simplificar:**
|
||||
- **Kadence Blocks `render_source_fields()`/`selector_element()` (§14)** — é código correcto mas
|
||||
bastante intrincado (resolve selectors CSS de volta para tag+classe HTML por heurística). Numa
|
||||
réplica que não precise de suportar Kadence Blocks especificamente, este é o tipo de
|
||||
complexidade a NÃO reinventar — só vale a pena se formos mesmo integrar Kadence Blocks.
|
||||
- **`Spectra_Catalog::real_attributes()` a fazer `require` de um ficheiro fonte de outro plugin**
|
||||
(§12.1) é frágil a mudanças de versão do Spectra (o caminho do ficheiro é hard-coded); preferir,
|
||||
quando possível, ler do `WP_Block_Type_Registry` (como Kadence faz) em vez de abrir ficheiros
|
||||
fonte de terceiros directamente — só recorrer a isto quando o plugin de terceiros não regista
|
||||
todos os atributos no registry core (razão pela qual o Spectra precisou desta técnica).
|
||||
|
||||
**Deixar de fora (a menos que haja procura real de clientes):**
|
||||
- As 4 integrações Pro-only (§16) — só valem a pena reimplementar quando houver um cliente
|
||||
concreto a usar GeneratePress/GenerateBlocks/Blocksy. Sem acesso ao código nem à base de
|
||||
utilizadores, seria trabalho especulativo.
|
||||
- `Plugin_Abilities`/`Theme_Abilities` install/update/delete completos (§4/§5) — install/delete
|
||||
arbitrário de plugins/temas via MCP é uma superfície de risco elevada (mesmo restrita a
|
||||
wordpress.org); para um clone interno de uso próprio da Descomplicar, ponderar reduzir a
|
||||
read-only (`list`/`search`) + `activate`/`deactivate`, deixando install/update/delete fora do
|
||||
MCP por preferir esse fluxo pelo wp-admin/WP-CLI directamente.
|
||||
|
||||
**Riscos/gotchas não óbvios encontrados no código (citações):**
|
||||
- Astra: cache de CSS dinâmico só invalida em `customize_save_after`, **não** numa escrita de
|
||||
option simples — sem a chamada explícita a `astra_clear_all_assets_cache()`, updates via MCP
|
||||
ficariam invisíveis até o próximo save do Customizer (§11).
|
||||
- Spectra: overlay de imagem de fundo em gradiente vem de `gradientValue`, **não** de
|
||||
`gradientOverlayColor1/2` como seria intuitivo — documentado explicitamente no
|
||||
`SHARED_ATTRS` do próprio código (§12.1).
|
||||
- Kadence Blocks `rowlayout`: `colLayout` vazio faz o editor mostrar um picker de layout em vez
|
||||
das colunas — "the frontend renders regardless" mas a experiência de edição fica quebrada; fix
|
||||
é forçar `colLayout: 'equal'` como default quando o caller não o especifica (§14.1).
|
||||
- Kadence Blocks construídos à mão (não via pattern) disparam "Attempt recovery" no editor porque
|
||||
não batem byte-a-byte com o `save()` estático JS — o front-end renderiza correctamente na mesma;
|
||||
só `insert-pattern` (markup canónico do próprio Kadence) evita o aviso (§14).
|
||||
- `upload-media`/`create-post` (featured_image sideload): as funções de sideload
|
||||
(`media_handle_sideload` e dependências) vivem em `wp-admin/includes/*` que **não** está
|
||||
carregado em pedidos REST/WP-CLI — têm de ser `require_once`'d on-demand a cada chamada (§1, §2).
|
||||
- `create-user`: erros de conta existente são normalizados para uma mensagem genérica
|
||||
precisamente para impedir que a tool sirva de oráculo de enumeração de contas (§6).
|
||||
|
||||
## Fonte
|
||||
|
||||
Leitura directa (19-08-2026) de: `includes/abilities/class-content-abilities.php`,
|
||||
`includes/abilities/class-media-library-abilities.php`,
|
||||
`includes/abilities/class-settings-abilities.php`,
|
||||
`includes/abilities/class-plugin-abilities.php`,
|
||||
`includes/abilities/class-theme-abilities.php`,
|
||||
`includes/abilities/class-user-abilities.php`,
|
||||
`includes/abilities/class-nav-menu-abilities.php`,
|
||||
`includes/abilities/class-image-resize-abilities.php`,
|
||||
`includes/abilities/class-theme-integration.php`,
|
||||
`includes/abilities/class-active-theme-integration.php`,
|
||||
`includes/abilities/class-astra-integration.php`,
|
||||
`includes/abilities/class-spectra-integration.php`,
|
||||
`includes/abilities/class-kadence-integration.php`,
|
||||
`includes/abilities/class-kadence-blocks-integration.php`,
|
||||
`includes/blocks-catalog/class-kadence-blocks-catalog.php`,
|
||||
`includes/blocks-catalog/class-kadence-pattern-library.php`,
|
||||
`includes/blocks-catalog/class-spectra-catalog.php`,
|
||||
`includes/class-child-theme-builder.php`; grep de
|
||||
`includes/abilities/class-ability-registrar.php` (linhas 135-165, 220-250, 320-380) para as
|
||||
condições de registo exactas e a confirmação da lista de 4 classes Pro-only referenciadas mas não
|
||||
implementadas; listagem directa de `includes/abilities/` (48 ficheiros) para confirmar a ausência
|
||||
física dos ficheiros GeneratePress/GenerateBlocks/Blocksy neste build Free. Cruzado com
|
||||
`docs/00-ARQUITECTURA.md` (mesma sessão) para o contrato `emcp_tools_register_ability()`.
|
||||
@@ -0,0 +1,687 @@
|
||||
# 04 — Themer (header/footer/single/archive/search/404, condições, render, blocos/widgets dinâmicos, Themer PHP)
|
||||
|
||||
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
|
||||
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
|
||||
19-08-2026. Módulo confirmado como o mais substancial do bundle Free (`skill://emcp-tools` §6-§7):
|
||||
CPT + condições + render controller + blocos + widgets + templates PHP — só a categoria "Themer
|
||||
(free)" já soma 8 tools sempre activas, mais 5 tools do sub-toggle "Themer PHP" quando ligado.
|
||||
|
||||
## 0. Visão geral e ciclo de vida do módulo
|
||||
|
||||
`EMCP_Tools_Themer_Module` (`includes/modules/class-themer-module.php`) estende
|
||||
`EMCP_Tools_Module`: `id()='themer'`, `tier()='free'`, `default_active()=true` — activo por
|
||||
omissão em qualquer instalação. `register()` é chamado pelo `EMCP_Tools_Modules_Registry` em
|
||||
`init:5`, só quando o módulo está activo, e é o único ponto de entrada que fia tudo:
|
||||
|
||||
```php
|
||||
public function register(): void {
|
||||
( new EMCP_Tools_Themer_CPT() )->register();
|
||||
if ( class_exists( 'EMCP_Tools_Themer_HFE_Conflict' ) ) { EMCP_Tools_Themer_HFE_Conflict::init(); }
|
||||
EMCP_Tools_Themer_Index::register_hooks();
|
||||
// one-time heal do índice (ver §6 — bug histórico de ordem de save)
|
||||
if ( ! is_admin() ) { ( new EMCP_Tools_Themer_Render_Controller() )->init(); }
|
||||
if ( is_admin() && class_exists( 'EMCP_Tools_Themer_Metabox' ) ) { ( new EMCP_Tools_Themer_Metabox() )->init(); }
|
||||
if ( class_exists( 'EMCP_Tools_Themer_Blocks' ) ) { ( new EMCP_Tools_Themer_Blocks() )->init(); }
|
||||
if ( class_exists( 'EMCP_Tools_Themer_Widgets' ) ) { ( new EMCP_Tools_Themer_Widgets() )->init(); }
|
||||
if ( class_exists( 'EMCP_Tools_Themer_PHP' ) ) { ( new EMCP_Tools_Themer_PHP() )->init(); }
|
||||
}
|
||||
```
|
||||
|
||||
**Gate de kill-switch verdadeiro:** desligar o módulo (`emcp_tools_active_modules` sem `themer`)
|
||||
pára o CPT, o take-over de front-end, a tab de admin — **e** o registrador de abilities
|
||||
(`class-ability-registrar.php`, linhas 202-211) omite as 8 tools do grupo Themer, porque o
|
||||
registo de abilities corre em `wp_abilities_api_init` (ANTES de `init:5`), pelo que a condição
|
||||
`EMCP_Tools_Themer_Module::is_enabled()` tem de ler a option `emcp_tools_active_modules`
|
||||
directamente em vez de depender de qualquer estado que só existiria depois do boot do módulo:
|
||||
|
||||
```php
|
||||
public static function is_enabled(): bool {
|
||||
$active = (array) get_option( EMCP_Tools_Module::OPTION_ACTIVE, array() );
|
||||
return in_array( 'themer', $active, true );
|
||||
}
|
||||
```
|
||||
|
||||
**Sub-toggle independente — Themer PHP:** as 5 tools `*-theme-php-template` só se registam
|
||||
quando, ADICIONALMENTE ao módulo Themer estar activo, a option própria
|
||||
`emcp_tools_themer_php_enabled` estiver a `'1'` (`EMCP_Tools_Themer_PHP::enabled()`,
|
||||
`includes/abilities/class-ability-registrar.php` linhas 213-221). É desligado por omissão em
|
||||
qualquer instalação nova.
|
||||
|
||||
**Estruturas de dados (todas nativas WordPress, zero tabelas SQL próprias):**
|
||||
|
||||
| Estrutura | Tipo | Papel |
|
||||
|---|---|---|
|
||||
| CPT `emcp_theme_template` | post type, `show_ui=true`, menu próprio | O template em si (título + conteúdo Elementor/Gutenberg/clássico) |
|
||||
| meta `_emcp_themer_type` | string | `header\|footer\|single\|archive\|search\|404` |
|
||||
| meta `_emcp_themer_conditions` | array `{include:Rule[], exclude:Rule[], priority:int}` | Condições de exibição |
|
||||
| meta `_emcp_themer_php_template` | int (post id) | Template PHP anexado a este slot (0/ausente = usa o conteúdo do builder) |
|
||||
| option `emcp_tools_themer_index` (autoloaded) | `{type => rows[]}` | Índice pré-computado, ver §6 — o "fast path" de zero queries no front-end |
|
||||
| option `emcp_tools_themer_index_healed` | `'1'` | Marcador do heal único (bug histórico, ver §6) |
|
||||
| option `emcp_tools_module_themer_force_render` | `'0'\|'1'` | Full-page takeover para temas não suportados |
|
||||
| option `emcp_tools_themer_php_enabled` | `'0'\|'1'` | Sub-toggle Themer PHP |
|
||||
| option `emcp_tools_hfe_conflict_dismissed` | `'1'` | Dispensa da notice de conflito com Ultimate Addons for Elementor |
|
||||
| CPT `emcp_theme_php` (privado, `show_ui=false`) | post type | Templates PHP em bruto (§11) |
|
||||
| meta `_emcp_theme_php_code`/`_type`/`_validation`/`_hash`/`_error` | — | Estado do template PHP |
|
||||
| ficheiro `{sandbox}/theme-php/{id}.php` + `theme-php-manifest.json` | filesystem | Função PHP compilada + manifesto hash-verificado (§11.2-11.3) |
|
||||
|
||||
**Filtros de extensão (o seam onde a versão Pro se encaixa sem qualquer código Pro na árvore
|
||||
free):**
|
||||
|
||||
| Filtro | Free devolve | O que o Pro acrescentaria |
|
||||
|---|---|---|
|
||||
| `emcp_themer_selectors` | 7 chaves largas (`entire-site`, `all-singular`, `all-archives`, `front-page`, `post-type`, `post-type-archive`, `tax-archive`) | Selectores granulares (`post`, `term`, `author`, `date`) — a mera PRESENÇA de `'post'` neste array é usada em vários sítios (`pro_conditions_available()`, `is_pro()`) como o teste "é Pro?" |
|
||||
| `emcp_themer_matchers` | 7 matchers correspondentes | Matchers para os selectores granulares acima |
|
||||
| `emcp_themer_condition_schema` | Só a relação Include + folhas largas | Relação Exclude + pesquisa de objecto específico + nós Author/Date/In-term |
|
||||
| `emcp_themer_rank` | `fn($row) => 0` (sem prioridade real) | Um ranker que lê `$row['priority']` de facto |
|
||||
| `emcp_themer_quota` | `1` (por tipo) | `PHP_INT_MAX` |
|
||||
| `emcp_themer_theme_adapters` | 7 temas mapeados (Astra/GeneratePress/Kadence/OceanWP/Blocksy/Neve/Hello Elementor) | Mais temas, ou pode ser estendido por qualquer terceiro |
|
||||
|
||||
---
|
||||
|
||||
## 1. Abilities MCP — `EMCP_Tools_Themer_Abilities` (`includes/abilities/class-themer-abilities.php`)
|
||||
|
||||
Regista sempre as 8 tools quando o módulo Themer está activo (gate em §0). Duas permissões
|
||||
partilhadas: `check_read_permission` (`edit_posts`) e `check_write_permission`
|
||||
(`edit_post($template_id)` se o id já existir; senão `publish_pages || edit_pages` para criação).
|
||||
|
||||
| Tool | Input schema (resumo) | O que faz | Permissão | readonly / destructive |
|
||||
|---|---|---|---|---|
|
||||
| `list-theme-templates` | `{ type?: string }` | `WP_Query` sobre `emcp_theme_template` (publish+draft, até 200), filtrável por tipo via `meta_key`; devolve `{templates: summary[]}` com `template_id, title, type, status, conditions, edit_url`. | `check_read_permission` | readonly, idempotent |
|
||||
| `get-theme-template` | `{ template_id: int }` **obrigatório** | Devolve `template_id, title, type, conditions, builder (elementor/gutenberg/classic via `Content_Renderer::detect_builder`), content` (post_content bruto). | `check_read_permission` | readonly, idempotent |
|
||||
| `list-condition-targets` | `{}` | Discovery para `set-template-conditions`: `selectors` (o set válido actual via `valid_selectors()`), `post_types` (todos os públicos), `taxonomies` (todas as públicas + `object_types`). | `check_read_permission` | readonly, idempotent |
|
||||
| `create-theme-template` | `{ type: enum(6 tipos)*, title?, content?, scope? }` | Cria o post CPT; aplica a quota 1-por-tipo (`Themer_CPT::can_create`) — devolve `{error}` se excedida; semeia um `scope` largo automático por omissão (header/footer→`entire-site`, single→`all-singular`, archive→`all-archives`); valida o `scope` contra `valid_selectors()` (selector inválido = template criado mas SEM condição, não falha); chama `Themer_Index::rebuild()` no fim. | `check_write_permission` | write, não destructive, não idempotent |
|
||||
| `update-theme-template` | `{ template_id: int*, title?, content? }` | `wp_update_post` parcial (só os campos passados). | `check_write_permission` | write, não idempotent |
|
||||
| `set-template-conditions` | `{ template_id: int*, include: object[]*, exclude?: object[], priority?: int }` | Valida cada regra (`include`+`exclude`) contra `valid_selectors()`; rejeita com erro se `exclude` não vazio e sem camada Pro (`pro_conditions_available()`); ignora silenciosamente `priority` não-zero em free (é um Pro tie-break); grava a meta `_emcp_themer_conditions`; `Themer_Index::rebuild()`. | `check_write_permission` | write, idempotent |
|
||||
| `delete-theme-template` | `{ template_id: int*, force?: bool }` | `wp_delete_post($id, $force)` — trash por omissão, `force=true` apaga definitivo; `Themer_Index::rebuild()`. | `check_write_permission` | **destructive** |
|
||||
| `resolve-template` | `{ post_id?: int, context?: enum(front-page,search,404) }` | Constrói um contexto (`Themer_Context::from_parts`) a partir do `post_id`/`context` dado, corre `Themer_Resolver::resolve()` com um registry fresco e o ranker `emcp_themer_rank` (free = 0), devolve `{slots:{header,body,footer}, context}` — verificação directa de "que template ganha aqui". | `check_read_permission` | readonly, idempotent |
|
||||
|
||||
**Nota de design em `execute_set_conditions`:** o comentário no código é explícito — regras
|
||||
`exclude` **não são silenciosamente ignoradas** em free, são **rejeitadas com erro**
|
||||
("Exclude rules require EMCP Pro"), porque um `exclude` que nunca é avaliado (porque não há
|
||||
matcher registado para o selector) equivaleria a um no-op silencioso — falhar alto evita que o
|
||||
agente pense que configurou uma exclusão que na verdade nunca aplica.
|
||||
|
||||
---
|
||||
|
||||
## 2. Abilities MCP — `EMCP_Tools_Themer_PHP_Abilities` (`includes/abilities/class-themer-php-abilities.php`)
|
||||
|
||||
5 tools, só registadas quando `EMCP_Tools_Themer_PHP::enabled()` (módulo Themer activo **E**
|
||||
`emcp_tools_themer_php_enabled='1'`). Duas permissões: escrita exige
|
||||
`EMCP_Tools_Themer_PHP_Store::can_edit()` (`manage_options` **E** `unfiltered_html` — a dupla
|
||||
capability é deliberada, ver §11.2); leitura exige `can_read()` (`manage_options`).
|
||||
|
||||
| Tool | Input schema (resumo) | O que faz | Permissão | readonly / destructive |
|
||||
|---|---|---|---|---|
|
||||
| `create-theme-php-template` | `{ code: string*, type: enum(header,footer,single,archive,any)* , title? }` | Cria um `emcp_theme_php` em DRAFT via `Themer_PHP_Store::create_draft()`; valida com `EMCP_Tools_PHP_Snippet_Validator` (parse PHP + heurísticas de segurança do sandbox partilhado, ver doc 06); rejeita se inválido/inseguro. **Não existe tool `attach`** — deliberado (ver §11 e blueprint). | `check_write_permission` | write, não destructive |
|
||||
| `list-theme-php-templates` | `{ type?: string }` | Lista drafts (`id, title, type, compiled, last_error`), filtro opcional por tipo. | `check_read_permission` | readonly, idempotent |
|
||||
| `get-theme-php-template` | `{ template_id: int* }` | Registo completo: código, tipo, estado compilado, relatório de validação. | `check_read_permission` | readonly, idempotent |
|
||||
| `update-theme-php-template` | `{ template_id: int*, title?, code?, type? }` | Actualização parcial; re-valida sempre; se já estava compilado (referenciado por um post Themer), recompila a partir do novo código. | `check_write_permission` | write, não idempotent |
|
||||
| `delete-theme-php-template` | `{ template_id: int* }` | Apaga o registo CPT **e** o ficheiro compilado no sandbox (`decompile()` + `wp_delete_post(force=true)`). | `check_write_permission` | **destructive**, idempotent |
|
||||
|
||||
**Blueprint-relevante:** o comentário de topo do ficheiro é a especificação do modelo de
|
||||
segurança inteiro numa frase — "*AI authors + validates DRAFT PHP templates; there is
|
||||
intentionally no attach tool — a human selects a template in the Themer metabox (the execution
|
||||
gate)*". Isto é o padrão mais reutilizável de todo este documento — ver blueprint final.
|
||||
|
||||
---
|
||||
|
||||
## 3. CPT + quota — `EMCP_Tools_Themer_CPT` (`includes/themer/class-themer-cpt.php`)
|
||||
|
||||
Regista `emcp_theme_template`: `public=false` mas `publicly_queryable=true` (deliberado — o
|
||||
comentário explica: permite ao iframe de preview do editor Elementor renderizar a própria vista
|
||||
singular do template; fica fora de menus/pesquisa/arquivos via `exclude_from_search=true`,
|
||||
`has_archive=false`, `rewrite=false`). `menu_position=21` (logo a seguir a Páginas). Suporta
|
||||
`title, editor, author, custom-fields`.
|
||||
|
||||
**Gotcha WordPress genérico, útil para qualquer CPT editável com Elementor:**
|
||||
`add_post_type_support( self::POST_TYPE, 'elementor' )` é OBRIGATÓRIO — o Elementor faz gate do
|
||||
seu editor em `post_type_supports($type, 'elementor')`; sem isto, "Edit with Elementor" não faz
|
||||
absolutamente nada (falha silenciosa, sem erro visível). Também precisa dos dois filtros
|
||||
`elementor/cpt_support/get_public_post_types` e `elementor/utils/get_public_post_types` para o
|
||||
Elementor listar o CPT nos sítios certos da UI.
|
||||
|
||||
**Quota (o mecanismo de "free = 1 por tipo"):**
|
||||
|
||||
```php
|
||||
public static function quota( string $type ): int {
|
||||
return (int) apply_filters( 'emcp_themer_quota', 1, $type ); // Pro sobe para PHP_INT_MAX
|
||||
}
|
||||
public static function can_create( string $type, int $existing_count ): bool {
|
||||
return $existing_count < self::quota( $type );
|
||||
}
|
||||
```
|
||||
|
||||
`count_of_type()` conta ao vivo via `WP_Query` (`found_posts`, sem cache) — chamado tanto pela
|
||||
ability `create-theme-template` (§1) como pela UI (`render_free_limits_notice()`, que desenha
|
||||
"chips" por tipo `used/cap` na lista do CPT).
|
||||
|
||||
**Duas heurísticas de UX no ecrã de listagem, dignas de nota como padrão de qualidade:**
|
||||
|
||||
1. **`render_adapter_notice()`** — mostra se o tema activo é directamente suportado pelo mapa de
|
||||
adapters (§5.3); se não for, explica as duas alternativas (tag `emcp_themer_location()` ou o
|
||||
toggle de full-page-takeover).
|
||||
2. **`render_type_mismatch_notice()`** — heurística de detecção de erro humano: percorre todos os
|
||||
templates e sinaliza (a) templates sem `type` definido (nunca renderizam), (b) um template
|
||||
`header`/`footer` cujo conteúdo contém elementos body-only (detecta por substring
|
||||
`emcp/post-title`, `emcp/archive-loop`, etc no `post_content`/`_elementor_data` — sinal de que
|
||||
o utilizador construiu conteúdo de página dentro de um template de header por engano), (c) um
|
||||
template cujo TÍTULO sugere um tipo diferente do `type` gravado (`type_hint_from_title()` —
|
||||
conservador, só palavras-chave inequívocas como "header"/"404"/"single"). Isto é puro código
|
||||
de qualidade-de-vida sem qualquer dependência de licença — vale a pena copiar tal-e-qual.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sistema de condições — o núcleo mais reutilizável do módulo
|
||||
|
||||
Arquitectura em 5 peças puras + 1 fio de ligação WordPress, desenhada para nunca tocar a BD no
|
||||
caminho crítico do front-end (ver §6 para o índice que torna isto possível).
|
||||
|
||||
### 4.1 Schema da UI — `EMCP_Tools_Themer_Condition_Schema` (`class-themer-condition-schema.php`)
|
||||
|
||||
`for_type(string $type): {relations, groups}` — constrói a árvore de opções em cascata que o
|
||||
metabox (JS `themer-conditions.js`) consome: Relação (`include`, +`exclude` via filtro Pro) →
|
||||
Grupo (`Entire site`/`Archives`/`Singular`, condicionados por tipo — header/footer vêem os 3,
|
||||
single só vê Singular, archive só vê Archives) → Sub-tipo (folha concreta, ex.
|
||||
`post-type-archive:{slug}` para cada post type com arquivo, `tax-archive:{slug}` para cada
|
||||
taxonomia pública). Free = só folhas largas; o filtro `emcp_themer_condition_schema` é o único
|
||||
ponto onde o Pro injecta pesquisa de objecto específico e os nós granulares.
|
||||
|
||||
### 4.2 Matcher registry — `EMCP_Tools_Themer_Matcher_Registry` (`class-themer-matcher-registry.php`)
|
||||
|
||||
Mapa `selector-key => {specificity: int, callback: fn(rule, ctx): bool}`. `fresh()` monta o
|
||||
registry free e aplica `apply_filters('emcp_themer_matchers', ...)`. `key()` extrai a chave antes
|
||||
do primeiro `:` do `object` da regra (`post-type:page` → chave `post-type`, parâmetro `page` via
|
||||
`param()`). `matches()`/`specificity()` são os dois métodos públicos que o resto do sistema usa —
|
||||
uma regra desconhecida NUNCA faz match (fail-closed).
|
||||
|
||||
Especificidades free: `entire-site=0` < `all-singular`/`all-archives=10` <
|
||||
`front-page`/`post-type`/`post-type-archive`/`tax-archive=20`. A escala é o que garante que "toda
|
||||
a categoria" nunca ganha sobre "categoria X" quando ambos aplicam (ver §4.5).
|
||||
|
||||
### 4.3 Avaliação pura — `EMCP_Tools_Themer_Conditions` (`class-themer-conditions.php`)
|
||||
|
||||
`evaluate({include, exclude}, ctx, registry): ?int` — função pura, sem I/O. Percorre `include`,
|
||||
guarda a MAIOR especificidade entre as regras que fazem match (`$best`); se nenhuma fizer match
|
||||
devolve `null` (não aplica). Senão, percorre `exclude`: qualquer match aí devolve `null`
|
||||
imediatamente (exclude ganha sempre a include). Caso contrário devolve `$best`. É este inteiro
|
||||
(ou `null`) que o resolver usa para desempatar entre templates concorrentes do mesmo tipo.
|
||||
|
||||
### 4.4 Contexto de pedido — `EMCP_Tools_Themer_Context` (`class-themer-context.php`)
|
||||
|
||||
`from_parts(array $parts): array` — normalizador puro, aplica defaults a TODAS as chaves
|
||||
(`is_singular, is_archive, is_search, is_404, is_front_page, is_home, is_post_type_archive,
|
||||
is_author, is_date, post_id, post_type, author_id, queried_post_type, queried_taxonomy,
|
||||
queried_term_id, term_ids`) para que matchers/testes nunca tenham de tratar chaves em falta.
|
||||
`from_query()` é o único ponto de contacto com WordPress: lê os condicionais da main query
|
||||
(`is_singular()`, etc) + `get_queried_object()`, incluindo `collect_terms()` (todos os term ids
|
||||
do post, por taxonomia) para suporte a `in-term` no Pro.
|
||||
|
||||
### 4.5 Resolução de slots — `EMCP_Tools_Themer_Resolver` (`class-themer-resolver.php`)
|
||||
|
||||
Função pura central: `resolve(index, ctx, registry, ranker): {header:?int, body:?int, footer:?int}`.
|
||||
|
||||
```php
|
||||
public static function body_type( array $ctx ): ?string {
|
||||
if ( $ctx['is_404'] ) return '404';
|
||||
if ( $ctx['is_search'] ) return 'search';
|
||||
if ( $ctx['is_singular'] ) return 'single';
|
||||
if ( $ctx['is_archive'] || is_post_type_archive || is_author || is_date || is_home )
|
||||
return 'archive';
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
Para cada slot (`header`, `body` — com o tipo dinâmico de `body_type()`, `footer`), `winner()`
|
||||
percorre as linhas candidatas do índice desse tipo, chama `Conditions::evaluate()` por linha, e
|
||||
escolhe segundo um critério de desempate em 3 níveis, por esta ordem: **(1) maior especificidade**
|
||||
(`$spec`), **(2) maior prioridade** (`$prio`, via `$ranker($row)` — free devolve sempre 0, logo
|
||||
este nível nunca decide nada em free), **(3) maior id** (o template mais recente ganha em caso de
|
||||
empate total). Este algoritmo — puro, testável isoladamente, zero acoplamento a WordPress — é o
|
||||
activo de engenharia mais valioso de todo o módulo.
|
||||
|
||||
### 4.6 `resolve-template` — como a ability expõe isto
|
||||
|
||||
`execute_resolve()` (§1) reconstrói um contexto a partir do input (`post_id` → singular; ou
|
||||
`context: front-page/search/404`) e chama exactamente o mesmo `Resolver::resolve()` que o
|
||||
front-end usa (via `Themer_Matcher_Registry::fresh()` + `Themer_Index::get()`), garantindo que a
|
||||
resposta da tool é sempre um espelho fiel do que realmente vai renderizar — não uma simulação
|
||||
paralela que possa divergir.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pipeline de render — o "motor híbrido"
|
||||
|
||||
### 5.1 `EMCP_Tools_Themer_Render_Controller` (`class-themer-render-controller.php`)
|
||||
|
||||
`init()` liga dois hooks: `template_include` (prioridade **99**, deliberadamente tardia — "*so we
|
||||
can defer to Elementor Pro's own theme builder when it wins*", ver `elementor_theme_builder_owns_body()`)
|
||||
e `template_redirect` (para injectar header/footer standalone).
|
||||
|
||||
`slots()` é **memoizado por pedido** (`private static $slots`) — resolve uma única vez por
|
||||
request, reutilizado por `render_mode()`, `maybe_take_over()`, `maybe_inject_parts()`, e pela
|
||||
função global `emcp_themer_location()`.
|
||||
|
||||
**`render_mode()` é a decisão de design mais importante do módulo — 3 modos:**
|
||||
|
||||
| Modo | Condição | Comportamento |
|
||||
|---|---|---|
|
||||
| `none` | Nenhum template `body` ganhou | Não mexe em nada — deixa o tema tratar tudo (um header/footer standalone ainda pode injectar via adapter) |
|
||||
| `body` | Há `body` mas NÃO (header E footer) | **Preserva o chrome do tema**: troca só a área de conteúdo (`template-body.php`) — chama `get_header()`/`get_footer()` do tema activo |
|
||||
| `full` | Há `body` **E** header **E** footer (ou a option `force_render='1'`) | **Takeover total**: documento standalone completo (`template-canvas.php`), zero chrome do tema |
|
||||
|
||||
Isto evita o erro clássico de plugins "theme builder": um utilizador que só quer substituir o
|
||||
`single.php` do tema NÃO perde acidentalmente o header/footer do tema só porque criou UM
|
||||
template body — o full takeover só acontece quando o admin conscientemente criou os 3 slots (ou
|
||||
forçou via option).
|
||||
|
||||
`maybe_take_over()` tem uma excepção crítica antes de qualquer resolução: ao editar/pré-visualizar
|
||||
o próprio CPT `emcp_theme_template`, serve sempre um canvas em branco
|
||||
(`template-edit-canvas.php`) — **nunca aplica a resolução Themer à própria vista singular do CPT**
|
||||
(evitaria um paradoxo: um template a tentar resolver-se a si próprio).
|
||||
|
||||
`maybe_inject_parts()` (em `template_redirect`) só corre quando o modo NÃO é `full` (evita
|
||||
duplicar header/footer). Chama `Themer_Theme_Adapters::current()`; se o tema for suportado,
|
||||
`wire_adapter()` faz `remove_all_actions($hook)` seguido de `add_action($hook, ...)` — **remove
|
||||
TODAS as callbacks existentes no hook do tema antes de adicionar a própria**, para o header do
|
||||
tema não renderizar ao lado/atrás do header Themer. Se o tema não for suportado e
|
||||
`force_render='1'`, cai para full-page takeover mesmo sem um template body (outro filtro em
|
||||
`template_include`, prioridade 100). Se nada disto aplicar, só a tag manual
|
||||
`emcp_themer_location('header'|'footer')` (que o próprio tema teria de chamar) funciona.
|
||||
|
||||
### 5.2 `EMCP_Tools_Themer_Content_Renderer` (`class-themer-content-renderer.php`)
|
||||
|
||||
`detect_builder(post_id): 'elementor'|'gutenberg'|'classic'` — inspecciona
|
||||
`_elementor_edit_mode='builder'` primeiro, senão `has_blocks($content)`. `render(post_id)`:
|
||||
|
||||
1. **Delegação PHP primeiro** — se o sub-módulo Themer PHP está activo e há um
|
||||
`_emcp_themer_php_template` anexado, chama `Themer_PHP_Renderer::render()`; se devolver algo
|
||||
não-vazio, usa isso e **pára aí** (o PHP template substitui o conteúdo do builder para essa
|
||||
região). Saída vazia cai de volta para o builder — nunca deixa a região em branco por um
|
||||
template PHP falhado.
|
||||
2. **Elementor** — `\Elementor\Core\Files\CSS\Post::create($id)->enqueue()` (garante o CSS gerado
|
||||
do template, que normalmente só é enfileirado no contexto da própria página, é injectado fora
|
||||
de contexto) + `Plugin::$instance->frontend->get_builder_content_for_display($id)`.
|
||||
3. **Gutenberg/clássico** — ambos passam por `apply_filters('the_content', $post->post_content)`
|
||||
(resolve blocos + shortcodes num único caminho).
|
||||
|
||||
Garantia de "nunca fatal num builder desconhecido": o caminho por omissão é sempre `the_content`.
|
||||
|
||||
### 5.3 `EMCP_Tools_Themer_Theme_Adapters` (`class-themer-theme-adapters.php`)
|
||||
|
||||
Mapa estático `template-slug => {header: hook, footer: hook}` para 7 temas populares (Astra,
|
||||
GeneratePress, Kadence, OceanWP, Blocksy, Neve, Hello Elementor), extensível via
|
||||
`emcp_themer_theme_adapters`. `current()` usa `get_template()` (slug do tema PAI, não do filho) —
|
||||
correcto para temas filhos.
|
||||
|
||||
### 5.4 `EMCP_Tools_Themer_HFE_Conflict` (`class-themer-hfe-conflict.php`)
|
||||
|
||||
Trata a colisão com "Ultimate Addons for Elementor" (UAE, antigo "Header Footer Elementor" — os
|
||||
hooks/nomes de classe internos ainda usam `HFE`). Ambos os sistemas constroem header/footer e
|
||||
injectam nos mesmos slots; sem mediação, dá dois headers ou uma vitória aleatória "quem se
|
||||
registou por último".
|
||||
|
||||
**Resolução determinística (não é só um aviso):** `filter_header()`/`filter_footer()`
|
||||
ligam-se a `enable_hfe_render_header`/`enable_hfe_render_footer`/`enable_hfe_render_before_footer`
|
||||
com prioridade 20 e **desligam o gate de render do HFE** (`return false`) para o slot que o Themer
|
||||
já resolveu para este pedido (`Render_Controller::slots()`) — Themer ganha sempre que tem
|
||||
template para o slot; o HFE continua a renderizar qualquer slot que o Themer não reclame. A
|
||||
verificação é deliberadamente conservadora: qualquer falha ao resolver devolve `false`
|
||||
(o Themer "não reclama"), nunca arrisca perder o header/footer do site por um bug de integração.
|
||||
|
||||
Adicionalmente mostra uma admin notice explicando o conflito, com um link para gerir módulos e um
|
||||
"Dismiss" persistido em option — UX de reconhecer um conflito real de ecossistema em vez de
|
||||
fingir que não existe.
|
||||
|
||||
---
|
||||
|
||||
## 6. Índice de condições (cache) — `EMCP_Tools_Themer_Index` (`class-themer-index.php`)
|
||||
|
||||
Uma ÚNICA option autoloaded (`emcp_tools_themer_index`) guarda `{type => rows[{id, include,
|
||||
exclude, priority}]}` — o resolver (§4.5) lê isto directamente, **zero queries à BD por pedido**
|
||||
no caminho de render (a option autoloaded já está em memória desde o boot do WordPress).
|
||||
|
||||
`build(records): index` é puro (registos planos → agrupados por tipo). `rebuild()` é o fio WP:
|
||||
`WP_Query` sobre `emcp_theme_template` **só `post_status='publish'`** (draft nunca aplica ao
|
||||
front-end — comentário explícito: "*a draft is work-in-progress and must not render for
|
||||
visitors*"), lê a meta de cada post, chama `build()`, grava a option.
|
||||
|
||||
**Bug histórico documentado + o mecanismo de "heal" que ficou no código como cicatriz
|
||||
permanente**, ordem dos hooks em `register_hooks()`:
|
||||
|
||||
```php
|
||||
// Priority 99: the metabox and the MCP abilities write the type/conditions
|
||||
// meta on save_post_{type} at priority 10, so the rebuild must run AFTER
|
||||
// them or it reads stale/absent meta and produces an empty index (which
|
||||
// makes the front end fall back to the theme's own templates).
|
||||
add_action( 'save_post_' . self::POST_TYPE, array( __CLASS__, 'rebuild' ), 99 );
|
||||
```
|
||||
|
||||
Se o rebuild corresse à prioridade 10 (ou sem prioridade explícita, ligando-se antes da metabox),
|
||||
lia a meta ANTES dela ser escrita — índice ficava vazio, templates paravam de aplicar
|
||||
silenciosamente. A correcção não foi só mudar a prioridade: o módulo carrega um marcador
|
||||
`OPTION_INDEX_HEALED` e, uma única vez por instalação afectada, força um `rebuild()` no boot para
|
||||
sites que já tinham este bug gravado no seu índice (`class-themer-module.php`, comentário
|
||||
"*One-time heal: a prior build could leave the condition index empty*").
|
||||
|
||||
**Lição de engenharia para a réplica:** qualquer sistema com um índice/cache derivado de meta
|
||||
escrita por MÚLTIPLAS fontes (aqui: metabox humana + 2 abilities MCP diferentes) precisa de uma
|
||||
ordem de prioridade EXPLÍCITA e testada, não implícita. Um bug deste tipo é invisível em testes
|
||||
manuais normais (a metabox humana normalmente já grava e o `save_post` global corre depois de
|
||||
qualquer forma) mas manifesta-se de forma imprevisível consoante QUEM escreveu por último.
|
||||
|
||||
`on_deleted_post()` liga-se a `deleted_post`/`trashed_post`/`untrashed_post`, filtra por tipo de
|
||||
post, e chama `rebuild()` — garante que apagar/mover-para-trash/restaurar um template também
|
||||
actualiza o índice.
|
||||
|
||||
---
|
||||
|
||||
## 7. Metabox de admin — `EMCP_Tools_Themer_Metabox` (`class-themer-metabox.php`)
|
||||
|
||||
UI server-driven: o PHP monta o `<select>` de tipo, o `<select>` opcional de template PHP
|
||||
anexado, e um `<div id="emcp-themer-conditions-app">` + `<input type="hidden">` com o JSON
|
||||
serializado das condições. Todo o construtor em cascata (Relação→Grupo→Sub-tipo) é montado
|
||||
client-side por `assets/js/themer-conditions.js` a partir do schema localizado
|
||||
(`emcpThemerCond.schemasByType`), com `emcpThemerCond.isPro` a controlar visibilidade de UI Pro.
|
||||
|
||||
**Decisão deliberada anti-erro-silencioso:** um template NOVO nunca herda um `type` por omissão —
|
||||
fica vazio até o utilizador escolher conscientemente (comentário: "*Do NOT default to a real type
|
||||
('header') — that silently mistyped templates*"). Só é pré-preenchido a partir de
|
||||
`?emcp_themer_type=` na URL (ex. um botão "Add New Header" que já passa o tipo pretendido).
|
||||
|
||||
**Detecção de conflito no próprio ecrã de edição:** `find_conflicts()` procura outros templates DO
|
||||
MESMO TIPO cujas condições `include` partilhem pelo menos um `object` (`entire-site`,
|
||||
`post-type:post`, etc — comparação por string exacta, não por especificidade) com o template
|
||||
actual, e mostra uma notice inline com links directos — porque só um template pode ganhar um dado
|
||||
slot, dois templates a apontar ao mesmo selector é quase sempre um erro do utilizador e o
|
||||
resolver (§4.5) resolve isto de forma silenciosa e não óbvia (especificidade→prioridade→id mais
|
||||
recente) sem esta notice.
|
||||
|
||||
`save()`: valida nonce, ignora autosave, verifica `edit_post`; grava `_emcp_themer_type` (só se
|
||||
válido); se Themer PHP activo, valida e aplica o attach do template PHP
|
||||
(`Themer_PHP_Admin::validate_attachment` + `apply_attachment` — este É o único ponto do sistema
|
||||
inteiro onde um template PHP passa de "draft nunca executa" a "compilado e a renderizar", e requer
|
||||
uma submissão de formulário humana com nonce, não uma chamada MCP); por fim
|
||||
`sanitize_conditions()` — descodifica o JSON, valida cada `object` contra `valid_selectors()`
|
||||
(regras com selector desconhecido são silenciosamente DESCARTADAS aqui, ao contrário da ability
|
||||
que REJEITA — inconsistência aceitável: a UI já só oferece selectores válidos, então uma regra
|
||||
"desconhecida" só chegaria por manipulação directa do campo hidden), e em free força sempre
|
||||
`exclude=[]`/`priority=0` mesmo que o payload tente enviar algo (dupla proteção, já para lá da
|
||||
rejeição da ability).
|
||||
|
||||
---
|
||||
|
||||
## 8. Conteúdo dinâmico partilhado — `EMCP_Tools_Themer_Dynamic` (`class-themer-dynamic.php`)
|
||||
|
||||
**A peça de design mais elegante do módulo**: um único catálogo estático de 10 elementos
|
||||
(`post-title, archive-title, breadcrumbs, post-meta, site-logo, site-title, nav-menu, description,
|
||||
post-content, archive-loop`), cada um com um método `public static function` que devolve HTML já
|
||||
escapado. Gutenberg (§9) e Elementor (§10) chamam exactamente os MESMOS métodos — a lógica de
|
||||
"o que é o título do post/arquivo agora" existe UMA VEZ, nunca duplicada por builder.
|
||||
|
||||
`args_from(key, rawAttrs): array` traduz os atributos de QUALQUER builder (Gutenberg `camelCase`
|
||||
booleanos JS `true/false`, ou Elementor `snake_case`/`'yes'/''`) para o formato de argumentos
|
||||
interno partilhado — normaliza truthy de ambos os mundos automaticamente. `render(key, args)` é o
|
||||
dispatcher final, usado directamente pelos dois builders.
|
||||
|
||||
Todos os elementos resolvem contra a **main query actual**, não o template — é assim que um
|
||||
template Themer "single" mostra o post realmente visitado. `queried_id()` cobre o caso `is_home()`
|
||||
(blog page separada). Detalhes de qualidade notáveis:
|
||||
|
||||
- **`breadcrumbs()`** prefere a função de breadcrumb de um plugin SEO já activo (Yoast/Rank
|
||||
Math/SEOPress, por esta ordem) antes de cair no trail simples próprio — evita reinventar
|
||||
algo que o site já pode ter bem configurado (schema, hierarquia custom, etc).
|
||||
- **`custom_field()`** é ACF-aware (`function_exists('get_field')`) com fallback a
|
||||
`get_post_meta()` — preenche o gap de "campo dinâmico" que nem Gutenberg nem Elementor free têm
|
||||
nativamente.
|
||||
- **`archive_loop()`** distingue explicitamente contexto real de preview
|
||||
(`is_preview_context()` — testa `REST_REQUEST`, `is_admin()`, modo editor/preview do Elementor)
|
||||
para usar a `$wp_query` real num arquivo a sério, ou uma `WP_Query` de amostra (respeitando
|
||||
`query_post_type`/`query_orderby`/`query_tax`/`query_term` opcionais) quando está só a ser
|
||||
desenhado — evita que o widget pareça "sem posts" enquanto o utilizador o configura.
|
||||
- **`is_preview_context()`** é reutilizado por vários pontos do módulo como o teste canónico
|
||||
"estou num contexto de edição, não num pedido real de visitante".
|
||||
|
||||
---
|
||||
|
||||
## 9. Blocos Gutenberg dinâmicos — `EMCP_Tools_Themer_Blocks` (`blocks/class-themer-blocks.php`)
|
||||
|
||||
Regista uma categoria própria (`emcp-themer`) e os 10 blocos `emcp/{key}` via
|
||||
`register_block_type()` API v2, com `render_callback` no servidor (nunca `save` client-side —
|
||||
todo o output é dinâmico). `blocks()` é a fonte única de verdade: título/ícone/`attributes`
|
||||
(tipos+defaults)/`supports` (align/color/spacing/typography/border nativos do editor)/`controls`
|
||||
(descritores partilhados de UI, ex. `{key:'tag', type:'select', options:[...]}`) — o MESMO array
|
||||
serve para registar o bloco em PHP **e** é `wp_localize_script()`'d para
|
||||
`assets/js/themer-blocks.js` construir os controlos `InspectorControls` no editor sem qualquer
|
||||
passo de build (JS vanilla, sem JSX/Webpack).
|
||||
|
||||
`render_block()` chama `Dynamic::args_from()` + `Dynamic::render()` (§8); se a saída for vazia
|
||||
E estivermos num pedido REST (preview do editor), mostra um placeholder com o título do bloco em
|
||||
vez de nada — o editor nunca parece "partido" mesmo quando não há dados de amostra.
|
||||
|
||||
---
|
||||
|
||||
## 10. Widgets Elementor dinâmicos — `EMCP_Tools_Themer_Widgets` + `Widget_Base` (`widgets/`)
|
||||
|
||||
`class-themer-widgets.php` é só o loader: liga `elementor/elements/categories_registered`
|
||||
(categoria "EMCP Themer"), `elementor/widgets/register` (`require_once` tardio de
|
||||
`class-themer-widget-classes.php` — só dentro deste hook, quando `\Elementor\Widget_Base` está
|
||||
garantidamente carregado), e reutiliza a MESMA folha de estilos dos blocos Gutenberg
|
||||
(`themer-blocks.css`) para o layout partilhado.
|
||||
|
||||
`class-themer-widget-classes.php` define `EMCP_Tools_Themer_Widget_Base` (abstract, estende
|
||||
`\Elementor\Widget_Base`) + **10 subclasses triviais** (uma linha cada: `emcp_key(): string`). A
|
||||
base faz TODO o trabalho:
|
||||
|
||||
- `register_controls()` — constrói os controlos de conteúdo a partir dos MESMOS descritores
|
||||
partilhados de `Themer_Blocks::blocks()[$key]['controls']` (mapeados para tipos de controlo
|
||||
Elementor via `control_args()`: select/toggle/text/number/menu) — de novo, zero duplicação entre
|
||||
a definição do bloco Gutenberg e do widget Elementor.
|
||||
- Tab de Estilo partilhada (alinhamento, cor de texto, cor de link, grupo de tipografia via
|
||||
`Group_Control_Typography`) igual em todos os 10 widgets; o widget `archive-loop`
|
||||
adicionalmente ganha uma secção "Cards" completa (gap, largura de imagem, fundo, borda, raio,
|
||||
padding, sombra, cores de título/meta/excerpt/read-more).
|
||||
- `render()` delega directamente a `Dynamic::render()` (§8) — o output já vem escapado do
|
||||
provider partilhado, por isso o `echo` aqui não escapa de novo (comentário explícito no código).
|
||||
|
||||
---
|
||||
|
||||
## 11. Subsistema Themer PHP — templates PHP em bruto autorados por IA
|
||||
|
||||
Feature mais sensível de todo o módulo (execução de código), desenhada com um modelo de segurança
|
||||
em camadas — a peça mais valiosa para copiar tal-e-qual numa réplica.
|
||||
|
||||
### 11.1 Coordenador — `EMCP_Tools_Themer_PHP` (`php/class-themer-php.php`)
|
||||
|
||||
Classe fina. `enabled()` é o único ponto de decisão: módulo Themer activo **E**
|
||||
`emcp_tools_themer_php_enabled='1'`. `init()` regista o CPT **incondicionalmente** (mesmo que o
|
||||
toggle esteja desligado — drafts existentes continuam consultáveis/apagáveis se a feature for
|
||||
depois desligada), mas só arranca o admin quando `enabled()`.
|
||||
|
||||
### 11.2 Store — `EMCP_Tools_Themer_PHP_Store` (`php/class-themer-php-store.php`)
|
||||
|
||||
CPT privado `emcp_theme_php` (`show_ui=false`, `show_in_rest=false`, `public=false` — invisível
|
||||
fora deste subsistema). Meta: `_emcp_theme_php_code` (raw PHP), `_type`, `_validation` (JSON do
|
||||
relatório do validador partilhado), `_hash` (sha256 — **a presença desta meta É a definição de
|
||||
"compilado"**), `_error` (última mensagem de fatal capturada).
|
||||
|
||||
**Permissões deliberadamente duplas:** `can_edit()` exige `manage_options` **E**
|
||||
`unfiltered_html` — a segunda capability é a que o WordPress core usa para gate de código PHP
|
||||
arbitrário (ex. editor de temas/plugins); herdar exactamente essa capability em vez de inventar
|
||||
uma nova é a escolha correcta (multisite normalmente REMOVE `unfiltered_html` de admins de site
|
||||
por omissão, fechando esta feature automaticamente nesses contextos).
|
||||
|
||||
**`create_draft()`/`update()`** correm sempre a validação partilhada
|
||||
(`EMCP_Tools_PHP_Snippet_Validator::validate()`, ver doc 06 — parse PHP + heurísticas de
|
||||
segurança) e recusam com `WP_Error` se `!valid` (erro de parse) ou `!safe` (finding crítico:
|
||||
execução de código, shell, carregamento de ficheiros, rede, escrita em ficheiros — listado na
|
||||
description da ability, §2).
|
||||
|
||||
**Compila-se apenas quando referenciado — o coração do modelo de segurança:**
|
||||
|
||||
```php
|
||||
public static function sync_reference( int $id ) {
|
||||
if ( self::reference_count( $id ) > 0 ) { return self::ensure_compiled( $id ); }
|
||||
self::decompile( $id );
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
`reference_count()` conta posts `emcp_theme_template` cuja meta `_emcp_themer_php_template`
|
||||
aponta para este id. Um draft criado/editado pela IA **não tem ficheiro `.php` em disco até um
|
||||
humano o anexar via metabox** (`Metabox::apply_attachment()`, §7, chama `sync_reference()` depois
|
||||
da meta gravada). Desanexar (ou apagar o post Themer que o referenciava) desfaz a compilação
|
||||
automaticamente. Isto reduz drasticamente a superfície de "código PHP a correr no site" ao
|
||||
subconjunto que um humano explicitamente ligou — a IA nunca consegue tornar um template
|
||||
executável por si só.
|
||||
|
||||
**`ensure_compiled()`** — re-valida, envolve o corpo (já com as tags PHP removidas por
|
||||
`Validator::strip_tags()`) numa função nomeada `emcp_theme_php_{id}` guardada por
|
||||
`function_exists()`, faz `token_get_all($php, TOKEN_PARSE)` como verificação extra de sintaxe
|
||||
antes de escrever, grava o ficheiro em `{sandbox}/theme-php/{id}.php`, e grava o sha256 do
|
||||
conteúdo final como `_hash`. `opcache_invalidate()` é chamado explicitamente após cada
|
||||
escrita/remoção de ficheiro (evita servir bytecode obsoleto em produção com OPcache).
|
||||
|
||||
**Manifesto** (`theme-php-manifest.json`): `rebuild_manifest()` percorre TODOS os drafts, inclui
|
||||
só os que têm `_hash` presente (i.e., compilados), grava `{post_id, func, php_path, hash, type}`
|
||||
por entrada — este ficheiro é a ÚNICA fonte que o renderer (§11.3) consulta, nunca faz scan de
|
||||
directório.
|
||||
|
||||
### 11.3 Renderer — `EMCP_Tools_Themer_PHP_Renderer` (`php/class-themer-php-renderer.php`)
|
||||
|
||||
`render(id): string` — devolve `''` em QUALQUER falha (o content-renderer, §5.2, cai de volta ao
|
||||
conteúdo do builder nesse caso). Três camadas de defesa antes de sequer chamar a função:
|
||||
|
||||
1. **Manifest-only lookup** — `manifest_entry()` procura no `read_manifest()`; nenhum ficheiro
|
||||
nunca é localizado por varrimento de directório.
|
||||
2. **Path containment guard** — `0 !== strpos(normalize($path), normalize($sandbox))` — o caminho
|
||||
resolvido tem de viver dentro do directório sandbox; defende um manifesto envenenado por
|
||||
qualquer via.
|
||||
3. **Tamper guard** — `hash('sha256', file_get_contents($path)) !== $entry['hash']` → recusa. O
|
||||
ficheiro em disco tem de bater exactamente com o hash gravado no manifesto no momento da
|
||||
compilação — qualquer edição directa do `.php` no disco (fora do fluxo Store) invalida-o
|
||||
silenciosamente.
|
||||
|
||||
Só depois de passar as 3 camadas é que `include_once` carrega o ficheiro (isto define a função —
|
||||
"*running no user code*" ainda, o corpo só corre quando chamada). A chamada em si acontece dentro
|
||||
de `ob_start()` + `try/catch(\Throwable)` — uma excepção marca o erro (`mark_error()`, que
|
||||
**também decompila** o template) e devolve `''`.
|
||||
|
||||
**Fatal-recovery via shutdown handler — a rede de segurança final:**
|
||||
|
||||
```php
|
||||
public static function on_shutdown(): void {
|
||||
if ( null === self::$active ) { return; }
|
||||
$err = error_get_last();
|
||||
$fatal = array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR );
|
||||
if ( is_array($err) && in_array($err['type'], $fatal, true) ) {
|
||||
EMCP_Tools_Themer_PHP_Store::mark_error( self::$active, $err['message'] ?? '...' );
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`self::$active` marca qual template está a incluir/executar; se o PROCESSO INTEIRO morrer com
|
||||
fatal error (algo que nem `try/catch` apanha — um erro de PHP verdadeiramente fatal, não uma
|
||||
`\Throwable`), o shutdown handler regista-o e chama `mark_error()`, que **decompila o template**
|
||||
(remove `_hash`, apaga o ficheiro). **Consequência prática:** um template com um bug que provoque
|
||||
fatal auto-recupera para "desligado" logo após o PRIMEIRO pedido que o parte — não continua a
|
||||
derrubar cada pedido subsequente do site. É o mecanismo mais importante para tornar "deixar uma IA
|
||||
escrever PHP que corre no site" aceitável em produção.
|
||||
|
||||
### 11.4 Admin UI — `EMCP_Tools_Themer_PHP_Admin` (`php/class-themer-php-admin.php`)
|
||||
|
||||
Submenu sob o CPT Themer (`edit.php?post_type=emcp_theme_template&page=emcp-themer-php`). Reusa
|
||||
`wp_enqueue_code_editor(['type'=>'text/x-php'])` — o MESMO CodeMirror que o editor nativo de
|
||||
temas/plugins do WordPress usa, com linting — em vez de reinventar um editor de código. Lista
|
||||
templates, e ao ver um (`?view={id}`) mostra o editor completo com título/tipo/código, guardar
|
||||
(re-valida, recompila se já anexado) e apagar. É código de admin puro, sem qualquer registo MCP —
|
||||
existe só para o humano que precisa de intervir manualmente num template gerado pela IA.
|
||||
|
||||
---
|
||||
|
||||
## Blueprint para réplica
|
||||
|
||||
**Copiar quase 1:1 (o valor está no design, não na implementação de detalhe):**
|
||||
|
||||
1. **O sistema de condições completo (§4)** — Schema/Matcher-Registry/Conditions/Context/Resolver
|
||||
é uma máquina de resolução de "qual template para este pedido" desenhada com disciplina pura +
|
||||
glue. É genérico o suficiente para servir QUALQUER sistema de "theme builder"/"template
|
||||
assignment" (não só Themer) — vale a pena extrair como um pacote isolado logo de início.
|
||||
2. **O padrão "catálogo partilhado + dispatcher" de `Themer_Dynamic` (§8)** — um único ponto de
|
||||
verdade para "o que é X neste contexto", consumido por N builders/superfícies diferentes (aqui
|
||||
Gutenberg e Elementor; podia ser qualquer par). Evita a divergência clássica "o título mostra
|
||||
uma coisa no bloco e outra no widget".
|
||||
3. **O modelo de segurança do Themer PHP inteiro (§11)** — compila-só-quando-referenciado +
|
||||
manifesto hash-verificado + `path containment guard` + shutdown fatal-recovery é a resposta
|
||||
correcta a "deixar um agente de IA escrever PHP executável" e generaliza-se a qualquer feature
|
||||
futura do género (snippets, sandbox de widgets/blocos custom — a doc 06 provavelmente reutiliza
|
||||
o mesmo `PHP_Snippet_Validator`/`PHP_Snippet_Store` subjacentes).
|
||||
4. **O `render_mode()` de 3 estados (none/body/full, §5.1)** — a decisão de nunca fazer takeover
|
||||
total do documento a menos que o admin tenha deliberadamente os 3 slots (ou tenha forçado) é a
|
||||
diferença entre "plugin de theme builder que não assusta ninguém" e "plugin que às vezes come o
|
||||
header do tema sem aviso".
|
||||
5. **A ausência deliberada de uma tool `attach` no grupo Themer PHP** — replicar o princípio
|
||||
directamente: **qualquer feature que gere código executável via IA deve ter o "ligar à
|
||||
execução" como um passo humano fora do protocolo MCP**, nunca uma ability chamável.
|
||||
|
||||
**Simplificar numa reescrita própria:**
|
||||
|
||||
- **Os 3 níveis do índice de condições (§6)** são bom design mas exigem disciplina de ordenação de
|
||||
hooks nada óbvia (o bug histórico de §6 prova isto). Numa reescrita, considerar calcular o
|
||||
resolve directamente a partir da CPT em cada pedido com `WP_Object_Cache`/transient de curto TTL
|
||||
em vez de uma option autoloaded mantida manualmente — mais simples de raciocinar, ao custo de
|
||||
uma query extra em cache-miss (aceitável face ao ganho de robustez).
|
||||
- **Os 7 theme adapters fixos (§5.3)** são um mapa estático de hooks específicos por tema —
|
||||
correcto para os temas mais populares mas frágil a longo prazo (nomes de hooks mudam entre
|
||||
versões major de tema). Considerar documentar isto como convenção pública (`emcp_themer_location()`
|
||||
já existe para esse fim) em vez de tentar manter uma lista de adapters actualizada
|
||||
indefinidamente.
|
||||
- **A UI de admin nativa completa (metabox + condition-builder JS + PHP-editor CodeMirror)** é
|
||||
~1500+ linhas de PHP mais JS não lido nesta tarefa (`assets/js/themer-conditions.js`,
|
||||
`assets/js/themer-blocks.js`) — para uma réplica focada em "agente MCP + template engine",
|
||||
considerar reduzir a UI humana ao mínimo (edição via qualquer builder já suportado + um ecrã de
|
||||
condições simples) e investir o esforço poupado na cobertura de testes do resolver puro (§4.5),
|
||||
que é o componente que realmente importa estar correcto.
|
||||
|
||||
**Riscos/gotchas não óbvios a não repetir sem pensar:**
|
||||
|
||||
- A verificação `check_write_permission` de `Themer_Abilities` aceita `publish_pages ||
|
||||
edit_pages` para CRIAÇÃO (sem `template_id` ainda) mas exige `edit_post($id)` específico para
|
||||
edição — replicar esta assimetria correctamente é fácil de errar (a tentação óbvia é usar a
|
||||
mesma capability para os dois casos, o que ou é permissivo demais na criação ou impossível na
|
||||
edição de um post ainda inexistente).
|
||||
- `is_pro()`/`pro_conditions_available()` testam a presença de `'post'` no array de selectores
|
||||
filtrado como proxy de "há licença Pro" — um padrão frágil (qualquer terceiro que registe um
|
||||
selector chamado `post` por acidente activaria funcionalidade Pro sem querer) mas simples;
|
||||
numa reescrita própria, preferir uma função de capability explícita (`is_premium()`, já usada em
|
||||
`Themer_CPT`) em vez de inferir por presença de string.
|
||||
- `render_type_mismatch_notice()` e `find_conflicts()` (heurísticas de UX) fazem `WP_Query` de até
|
||||
100-200 posts em CADA carregamento do ecrã de admin relevante — aceitável à escala de "poucos
|
||||
templates de tema por site" mas não escalaria a um cenário de centenas de templates; não é um
|
||||
problema real neste domínio (o próprio quota de 1-por-tipo em free e o uso normal em Pro nunca
|
||||
chega a esses números), mas vale registar se a réplica reutilizar este padrão noutro contexto de
|
||||
volume maior.
|
||||
|
||||
---
|
||||
|
||||
## Fonte
|
||||
|
||||
Leitura directa (19-08-2026) de todos os 22 ficheiros do módulo Themer em
|
||||
`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`:
|
||||
|
||||
`includes/abilities/class-themer-abilities.php`, `includes/abilities/class-themer-php-abilities.php`,
|
||||
`includes/themer/class-themer-cpt.php`, `includes/themer/class-themer-resolver.php`,
|
||||
`includes/themer/class-themer-conditions.php`, `includes/themer/class-themer-condition-schema.php`,
|
||||
`includes/themer/class-themer-dynamic.php`, `includes/themer/class-themer-metabox.php`,
|
||||
`includes/themer/class-themer-matcher-registry.php`, `includes/themer/class-themer-context.php`,
|
||||
`includes/themer/class-themer-render-controller.php`, `includes/themer/class-themer-content-renderer.php`,
|
||||
`includes/themer/class-themer-hfe-conflict.php`, `includes/themer/class-themer-theme-adapters.php`,
|
||||
`includes/themer/class-themer-index.php`, `includes/themer/blocks/class-themer-blocks.php`,
|
||||
`includes/themer/widgets/class-themer-widgets.php`, `includes/themer/widgets/class-themer-widget-classes.php`,
|
||||
`includes/themer/php/class-themer-php.php`, `includes/themer/php/class-themer-php-store.php`,
|
||||
`includes/themer/php/class-themer-php-renderer.php`, `includes/themer/php/class-themer-php-admin.php`.
|
||||
|
||||
Mais, para contexto do ciclo de vida (não listado no pedido original mas necessário para
|
||||
compreender o gate de activação em §0): `includes/modules/class-themer-module.php`. Verificação
|
||||
cruzada do gating exacto (linhas 202-221) contra `includes/abilities/class-ability-registrar.php`.
|
||||
Cruzado com `skill://emcp-tools` (auditoria de postura de segurança, 16-08-2026) e
|
||||
`docs/00-ARQUITECTURA.md` (arquitectura geral do plugin, já escrito nesta série).
|
||||
@@ -0,0 +1,727 @@
|
||||
# 05 — Redirects, Search Index, Page Snapshot e Change-Ledger/Content-Mirror
|
||||
|
||||
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
|
||||
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
|
||||
19-08-2026. Contexto herdado de `00-ARQUITECTURA.md` (cadeia de arranque, contrato
|
||||
`emcp_tools_register_ability()`, dispatcher compacto) e `skill://emcp-tools` (postura de
|
||||
segurança activa/desligada por site) — não repetidos aqui salvo onde relevante para os
|
||||
quatro subsistemas abaixo.
|
||||
|
||||
Este documento cobre **quatro subsistemas independentes** que partilham uma característica:
|
||||
são todos **"always-on"** (registados incondicionalmente em `class-ability-registrar.php`,
|
||||
sem gate de plugin de terceiros nem de licença Pro) — **excepto o Redirect Manager**, que é o
|
||||
único dos quatro atrás de um module gate (`redirects`, free, activo por omissão). Isto é um
|
||||
sinal de design deliberado: o autor considera pesquisa de conteúdo, snapshot de página,
|
||||
ledger de alterações e mirror de conteúdo **infra-estrutura nuclear** do plugin, não
|
||||
funcionalidades opcionais — mesmo o Redirect Manager, apesar de "module-gated", vem activo
|
||||
por omissão em todos os sites verificados.
|
||||
|
||||
## 0. Nota sobre um ficheiro fora do agrupamento temático: `class-url-guard.php`
|
||||
|
||||
`includes/class-url-guard.php` (`EMCP_Tools_Url_Guard`) foi incluído na lista de ficheiros
|
||||
desta tarefa mas **não pertence funcionalmente** a nenhum dos quatro subsistemas — é um
|
||||
**serviço SSRF partilhado**, usado pelas tools de sideload de imagem/SVG (doc 09) e, desde a
|
||||
v3.2.0, por uma validação mais estrita usada por uma tool `web_fetch` de AI Chat (Pro, fora
|
||||
deste build). Documentado em separado na §6 por completude do ficheiro pedido, mas não faz
|
||||
parte da arquitectura de Redirects/Search/Snapshot/Ledger em si.
|
||||
|
||||
---
|
||||
|
||||
## 1. Redirect Manager
|
||||
|
||||
**Classe de abilities:** `EMCP_Tools_Redirect_Abilities`
|
||||
(`includes/abilities/class-redirect-abilities.php`)
|
||||
|
||||
**Condição de registo** (copiada de `class-ability-registrar.php`, linhas 162-169):
|
||||
```php
|
||||
// Redirect Manager abilities (301/302 redirects + broken-link scan; no
|
||||
// Elementor). Gated on the Redirects module (on by default) — abilities
|
||||
// register before the module boots on init:5, so gate on is_enabled().
|
||||
if ( class_exists( 'EMCP_Tools_Redirect_Module' ) && EMCP_Tools_Redirect_Module::is_enabled() ) {
|
||||
$redirects = new EMCP_Tools_Redirect_Abilities();
|
||||
$redirects->register();
|
||||
$this->ability_names = array_merge( $this->ability_names, $redirects->get_ability_names() );
|
||||
}
|
||||
```
|
||||
**Ponto de design a reter:** as abilities registam-se em `wp_abilities_api_init`, que corre
|
||||
**antes** do módulo arrancar (`init:5`) — por isso o gate não pode depender de estado de
|
||||
instância do módulo; `EMCP_Tools_Redirect_Module::is_enabled()` tem de ser uma leitura
|
||||
**estática e sem efeitos secundários** (lê `emcp_tools_active_modules` directamente do
|
||||
option). Este é o padrão correcto para qualquer grupo de abilities gated a um módulo numa
|
||||
réplica: nunca depender da ordem de hooks do próprio módulo.
|
||||
|
||||
Não é Elementor-dependente (funciona em qualquer site, mesmo sem Elementor activo).
|
||||
|
||||
### 1.1 Tools
|
||||
|
||||
Todas as 5 tools partilham `permission_callback` = `check_manage_permission()` →
|
||||
`current_user_can('manage_options')` — **inclusive as de leitura** (`list-redirects`,
|
||||
`find-broken-links`), ao contrário dos outros três subsistemas deste documento que usam
|
||||
`edit_posts` para leitura. Reflecte que redirects tocam routing de produção com impacto
|
||||
directo em SEO — o autor optou por um limiar de permissão mais alto mesmo para inspecção.
|
||||
|
||||
| Tool | Input schema (resumo) | O que faz | `meta.annotations` |
|
||||
|---|---|---|---|
|
||||
| `list-redirects` | `enabled?:bool`, `search?:string`, `per_page?:int` (1-500, def 100), `page?:int` (def 1) | Lista redirects da tabela `{prefix}emcp_redirects` com filtro/paginação; devolve `{redirects[], total}`. | readonly, não-destructive, idempotent |
|
||||
| `create-redirect` | `source*:string`, `target?:string` **OU** `target_post_id?:int` (mutuamente exclusivos), `status_code?:enum[301,302]` (def 301), `ignore_query?:bool` (def true). `required:[source]` | Cria um redirect 301/302. **Avisa mas não bloqueia** quando o `source` já resolve para uma página publicada e viva (`shadow_warning()` — `url_to_postid()` + `get_post_status()==publish` → devolve `warning` no output em vez de recusar). Toda escrita passa por `EMCP_Tools_Change_Recorder::record_redirect()`. | não-readonly, não-destructive, não-idempotent |
|
||||
| `update-redirect` | `id*:int` + qualquer subconjunto de `source/target/target_post_id/status_code/ignore_query/enabled`. `required:[id]` | Actualiza só os campos fornecidos (patch parcial). Grava o antes (`$prior`) antes de aplicar, para o ledger. | não-readonly, não-destructive, **idempotent=true** |
|
||||
| `delete-redirect` | `id*:int`. `required:[id]` | Elimina por id. Reversível a partir da tab History. | não-readonly, **destructive=true**, não-idempotent |
|
||||
| `find-broken-links` | `max_posts?:int` (1-2000, def 200), `max_seconds?:int` (1-60, def 10) | Varre `post_content` de todos os post types públicos e publicados, extrai `href=` via regex, classifica cada link interno como `external/ok/dead/redirected` contra as fontes de redirect activas. **Só leitura — propõe, não corrige nada.** Limitado por posts E por tempo (`microtime()`), devolve `partial:true` se algum limite disparar a meio. | readonly, não-destructive, idempotent |
|
||||
|
||||
### 1.2 `EMCP_Tools_Redirect_Handler` — o hook de front-end
|
||||
|
||||
`includes/redirects/class-redirect-handler.php`. Regista-se em `template_redirect` prioridade
|
||||
**1** (o mais cedo possível, antes de qualquer templating de 404). `should_skip()` recusa
|
||||
disparar em `is_admin()`, `wp_doing_cron()`, `wp_is_json_request()`, e por regex em
|
||||
`wp-admin|wp-json|wp-login.php` no `REQUEST_URI` cru (defesa redundante ao `is_admin()`/REST
|
||||
check para o caso de esses helpers ainda não estarem disponíveis nesta fase tão cedo do ciclo
|
||||
de vida). Hot path: `normalize_path($uri)` → `find_by_source()` (lookup indexado único) →
|
||||
`resolve_target()` → `would_loop()` guard → forward do query string original SE o target não
|
||||
tiver já um `?` → `record_hit()` (incrementa contador+timestamp) → `wp_redirect($target,$code)`
|
||||
+ `exit`.
|
||||
|
||||
**Gotcha observado (não documentado no código, inferido por leitura cruzada):** o campo
|
||||
`ignore_query` é capturado e persistido na tabela, mas **`maybe_redirect()` nunca o lê**. O
|
||||
matching é sempre por `source_path` normalizado (que já descarta a query string em
|
||||
`normalize_path()`, através de `wp_parse_url($s)['path']`), logo a query é **sempre**
|
||||
ignorada para efeitos de correspondência, independentemente do valor de `ignore_query`. O
|
||||
que o handler efectivamente usa a query original para é só reencaminhá-la para o alvo quando
|
||||
este não já tiver a sua própria (`?query`). Ou este campo é vestigial (pensado para um modo
|
||||
de correspondência exacta com query que nunca chegou a ser implementado), ou é
|
||||
intencionalmente sempre-true na prática e o toggle serve outro propósito ainda não coberto
|
||||
por estes ficheiros (ex.: UI apenas). **Numa réplica, decidir explicitamente** um dos dois:
|
||||
implementar correspondência exacta por query quando `ignore_query=false`, ou remover o campo.
|
||||
|
||||
### 1.3 `EMCP_Tools_Redirect_Store` — tabela própria + CRUD + normalização
|
||||
|
||||
`includes/redirects/class-redirect-store.php`. Segue o mesmo padrão de storage do
|
||||
Search Index (§2): tabela custom `{prefix}emcp_redirects`, `DB_VERSION` const (`1`) + option
|
||||
`emcp_tools_redirects_db_version`, instalação via `dbDelta()` gated em `init:20`
|
||||
(`maybe_install()`, corre só quando `get_option(DB_VERSION_OPTION,0) < DB_VERSION`).
|
||||
|
||||
Schema:
|
||||
```sql
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
source_path VARCHAR(191) NOT NULL, -- UNIQUE KEY source_unique
|
||||
target TEXT NOT NULL,
|
||||
target_post_id BIGINT UNSIGNED NULL,
|
||||
status_code SMALLINT NOT NULL DEFAULT 301,
|
||||
match_type VARCHAR(20) NOT NULL DEFAULT 'exact', -- ver nota abaixo
|
||||
ignore_query TINYINT(1) NOT NULL DEFAULT 1,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
hits BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_hit DATETIME NULL,
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
KEY enabled_idx (enabled)
|
||||
```
|
||||
**`match_type` é escrito sempre como `'exact'`** por `create()`/`row_for_write()` — nenhum
|
||||
código nestes ficheiros lê ou ramifica sobre este valor. É claramente uma coluna preparada
|
||||
para um futuro modo de correspondência (prefixo/regex/wildcard) que ainda não existe — outro
|
||||
campo "de intenção futura" como o `ignore_query`.
|
||||
|
||||
**Helpers puros (sem BD, testáveis sem WordPress a correr, comentário explícito no header do
|
||||
ficheiro):**
|
||||
- `normalize_path()` — normaliza URL/path para path comparável home-relative: extrai só
|
||||
`path` via `wp_parse_url`, remove prefixo de subdirectório (`home_url('/')`), descodifica
|
||||
(`rawurldecode`), lowercase, colapsa `//` repetidos, garante 1 slash inicial, remove slash
|
||||
final; raiz mantém-se `/`.
|
||||
- `would_loop($source,$target)` — `normalize_path(source) === normalize_path(target)`.
|
||||
- `resolve_target($row)` — quando `target_post_id>0`, resolve o permalink **ao vivo**
|
||||
(`get_permalink()`) em vez de guardar a URL estática — sobrevive a mudanças de slug do
|
||||
próprio post de destino; devolve `''` (redirect tratado como inactivo) se o post já não
|
||||
existir.
|
||||
|
||||
**Validações em `create()`** (ordem exacta): source vazio/raiz rejeitado → source>191 chars
|
||||
rejeitado → `target` E `target_post_id` em simultâneo rejeitado (`ambiguous_target`) → nenhum
|
||||
dos dois rejeitado (`missing_target`) → self-loop rejeitado (`redirect_loop`) → source
|
||||
duplicado rejeitado (`duplicate_source`, via `find_by_source()` antes do INSERT — não confia
|
||||
só na UNIQUE KEY da BD).
|
||||
|
||||
**`rollback($rb)` — o applier do tipo `redirect-row` do ledger** (chamado a partir de
|
||||
`EMCP_Tools_Change_Log::apply_rollback()`, §4.4): 3 formas conforme a acção original —
|
||||
`create` → `before:{id}` → apaga a linha; `update`/`delete` → `before:{row:{...linha
|
||||
completa}}` → restaura/reinsere a linha completa preservando o `id` original
|
||||
(`row_for_write()` mapeia todas as colunas incluindo `id`, com formatos `%d/%s/%s/%d/…`
|
||||
próprios para insert vs update, via `write_formats()`/`write_formats_no_id()`). **Nota
|
||||
arquitectural importante:** este applier **não vive no dispatcher central**
|
||||
(`class-change-log.php`) — vive na própria classe de domínio (`Redirect_Store`), e o
|
||||
dispatcher central limita-se a `EMCP_Tools_Redirect_Store::rollback($rb)` dentro do seu
|
||||
`switch`. Ver §4 para o significado disto no design geral do ledger.
|
||||
|
||||
---
|
||||
|
||||
## 2. Índice de pesquisa de conteúdo (Content Search Index)
|
||||
|
||||
**Classe de abilities:** `EMCP_Tools_Search_Abilities` (`includes/abilities/class-search-abilities.php`)
|
||||
— sempre registada (linhas 186-189 do registrar: `// Content search — lexical index over
|
||||
pages/templates/widgets/globals (always-on).`), sem qualquer `class_exists()`/module gate.
|
||||
|
||||
### 2.1 Tools
|
||||
|
||||
Ambas usam `check_read_permission()` → `current_user_can('edit_posts')`. **Diferença notável
|
||||
face ao Redirect Manager:** nenhuma das duas chamadas `emcp_tools_register_ability()` inclui
|
||||
um bloco `meta` explícito (nem `output_schema`) — ao contrário de todas as tools do Redirect
|
||||
Manager. Como `emcp_tools_register_ability()` não injecta um `meta.annotations` por omissão
|
||||
visível nestes ficheiros, o comportamento efectivo (readonly/destructive) para clientes MCP
|
||||
que inspeccionam anotações fica indefinido/omisso para estas duas tools — inconsistência de
|
||||
estilo entre grupos de abilities do mesmo plugin, a evitar numa réplica (declarar sempre
|
||||
`meta.annotations`, mesmo quando óbvio).
|
||||
|
||||
| Tool | Input schema (resumo) | O que faz |
|
||||
|---|---|---|
|
||||
| `search-content` | `query*:string`, `types?:array<enum page,template,widget,global_color,global_font,global_class>`, `limit?:int` (def 20). `required:[query]` | Pesquisa o índice léxico materializado, devolve `{query, results[], count}`. Cada resultado: `{object_type, object_id, title, score, snippet, meta}`. |
|
||||
| `reindex-search` | `types?:array<mesmo enum>` (vazio = todos) | Reconstrói o índice (total ou parcial), devolve `{indexed:{tipo:contagem}, total}`. |
|
||||
|
||||
### 2.2 `EMCP_Tools_Search_Index` — a tabela + os document builders
|
||||
|
||||
`includes/class-search-index.php`. Tabela custom `{prefix}emcp_search_index`, `DB_VERSION=1`,
|
||||
option `emcp_tools_search_index_db_version`, instalação `dbDelta()` gated em `init:20`.
|
||||
|
||||
```sql
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
object_type VARCHAR(20) NOT NULL,
|
||||
object_id VARCHAR(64) NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content LONGTEXT NOT NULL,
|
||||
tokens LONGTEXT NOT NULL, -- ver nota "coluna morta" abaixo
|
||||
meta LONGTEXT NULL, -- JSON
|
||||
updated_at INT NOT NULL,
|
||||
UNIQUE KEY object_unique (object_type, object_id),
|
||||
KEY object_type_idx (object_type)
|
||||
```
|
||||
|
||||
**Achado — coluna `tokens` é escrita mas nunca lida:** `upsert()` calcula
|
||||
`EMCP_Tools_Search_Ranker::tokenize(title.' '.content)` e guarda-o em `tokens` a cada
|
||||
inserção/substituição, mas `search()` faz `SELECT object_type,object_id,title,content,meta`
|
||||
— **`tokens` não entra na query**. O `EMCP_Tools_Search_Ranker::rank()` retokeniza
|
||||
`title`+`content` **ao vivo**, a cada pesquisa, para todos os documentos do tipo filtrado.
|
||||
Isto é armazenamento morto: escreve-se trabalho computacional (tokenização) numa coluna que
|
||||
nunca é consultada, e o custo de tokenização repete-se em runtime a cada pesquisa em vez de
|
||||
ser amortizado. **Numa réplica:** ou remover a coluna, ou (melhor) usar FULLTEXT MySQL sobre
|
||||
`tokens` e evitar o full-table-scan + retokenização em PHP a cada pesquisa — a v1 "lexical"
|
||||
descrita no header do ranker é claramente um MVP consciente disto (comentário: "the
|
||||
embedding-backed rerank is a future upgrade layered on top of this").
|
||||
|
||||
**Hooks de manutenção** (`init()`): `init:20` instala; `save_post:30` reindexa
|
||||
incrementalmente; `deleted_post:10` remove do índice.
|
||||
|
||||
`on_save_post()` — cadeia de guardas antes de reindexar: ignora autosave → ignora revisão →
|
||||
ignora se a tabela ainda não tem a versão instalada → **ignora se
|
||||
`EMCP_Tools_Data::elementor_documents_ready()` for falso** (guarda contra um fatal
|
||||
documentado como **issue #105**: o Elementor insere o seu kit por omissão durante a sua
|
||||
**própria** activação, um `save_post` que aterra aqui antes do document manager do Elementor
|
||||
existir — indexar nesse momento desreferenciaria um manager nulo e provocaria fatal na
|
||||
activação do Elementor) → se o post gravado for o **kit activo**
|
||||
(`elementor_active_kit` option), reindexa só os globais (`index_globals()`) em vez de o
|
||||
tratar como um "template" comum → senão, indexa como `template` (post_type
|
||||
`elementor_library`) ou `page` (`page`/`post` com `_elementor_edit_mode=builder`).
|
||||
|
||||
`rebuild($types)` — 4 grupos independentes, cada um com `clear_type()` (DELETE por tipo)
|
||||
antes de reindexar:
|
||||
- **widgets** — `widget_documents()`: percorre `EMCP_Tools_Widget_Catalog::get()` (catálogo
|
||||
PHP estático, doc 02 — não vem de BD nem de posts), constrói `content` a partir de
|
||||
title+use_case+keywords+category+widget_type(normalizado)+nomes dos parâmetros.
|
||||
- **pages** — `WP_Query` sobre `page`/`post`, **todos os status**
|
||||
(`publish/draft/pending/private/future`), filtro `_elementor_edit_mode=builder`.
|
||||
- **templates** — `WP_Query` sobre `elementor_library`, mesmos status.
|
||||
- **globals** — lê `_elementor_page_settings` do kit activo (`elementor_active_kit`),
|
||||
indexa `system_colors`+`custom_colors` como `global_color` e
|
||||
`system_typography`+`custom_typography` como `global_font`; conteúdo inclui o valor
|
||||
(cor hex / nome da fonte) para que a pesquisa por valor também funcione.
|
||||
|
||||
**`page_document()`** — reutiliza directamente os helpers puros do Page Snapshot (§3):
|
||||
`EMCP_Tools_Page_Snapshot::normalize_tree()` (para os tipos de widget usados, normalizados
|
||||
`-`/`_`→espaço), `content_stats()` (para o texto dos headings), `extract_tokens()` (ids de
|
||||
cores/classes globais em uso) — mais uma recolha recursiva de `label`s de elementos
|
||||
(`collect_labels()`). **Ponto de arquitectura a reter para a réplica:** o índice de pesquisa
|
||||
não tem a sua própria lógica de leitura de árvore Elementor — delega inteiramente à camada
|
||||
Page Snapshot, evitando duplicar o parsing de settings Elementor em dois sítios.
|
||||
|
||||
### 2.3 `EMCP_Tools_Search_Ranker` — TF-IDF léxico puro
|
||||
|
||||
`includes/class-search-ranker.php`. **Zero dependência de WordPress** excepto um
|
||||
`apply_filters()` opcional guardado por `function_exists()` — pode correr em testes
|
||||
unitários puros sem framework nenhum, exactamente como o header documenta.
|
||||
|
||||
- `tokenize()` — lowercase, split por `[^a-z0-9]+`, descarta tokens <2 chars, descarta uma
|
||||
stopword-list curada pequena (inglês; nada de PT-PT — relevante para uma réplica com
|
||||
conteúdo em português, onde esta lista teria de ser adaptada ou substituída).
|
||||
- `rank($docs,$query,$limit)` — TF-IDF campo-ponderado: `TITLE_BOOST=3.0` (ocorrências no
|
||||
título contam 3x face ao corpo), IDF calculado por `log(1 + (n-df+0.5)/(df+0.5))` (fórmula
|
||||
tipo BM25 mas sem o factor de saturação de frequência de termo completo — é uma
|
||||
aproximação simplificada, não BM25 puro), score final `sum((tf/(tf+1)) * idf)` por termo
|
||||
da query presente no documento.
|
||||
- **Seam explícito para reranking futuro:** `apply_filters('emcp_tools_search_rerank',
|
||||
$scored, $query)` corre **depois** da ordenação léxica e **antes** do `array_slice` ao
|
||||
limite — comentário no código di-lo directamente: "This is the seam for an
|
||||
embedding-backed reranker (a future Pro upgrade); the lexical order is the default."
|
||||
- `snippet()` — extrai ~120 chars à volta da primeira ocorrência do primeiro termo da query
|
||||
encontrado (não do termo com melhor score — é posicional, não semântico).
|
||||
|
||||
---
|
||||
|
||||
## 3. Page Snapshot
|
||||
|
||||
**Classe de abilities:** `EMCP_Tools_Snapshot_Abilities` (`includes/abilities/class-snapshot-abilities.php`)
|
||||
— sempre registada (linhas 176-179 do registrar: `// Page Snapshot — always-on normalized
|
||||
page digest (read foundation).`), recebe `EMCP_Tools_Data` injectado no construtor (dependência
|
||||
partilhada com quase todo o resto do plugin, doc 01).
|
||||
|
||||
### 3.1 Tool
|
||||
|
||||
| Tool | Input schema (resumo) | O que faz |
|
||||
|---|---|---|
|
||||
| `get-page-snapshot` | `post_id*:int`, `include?:array<enum performance,a11y,seo>`, `sections?:array<enum post,structure,tokens,responsive,content,seo_lite,warnings>`, `fresh?:bool`. `required:[post_id]` | Devolve **um** digest normalizado da página em vez de forçar o agente a encadear `get-page-structure`+`get-global-settings`+`list-global-classes`+etc. |
|
||||
|
||||
`permission_callback` = `check_read_permission()` → `edit_posts`. Também sem bloco `meta`
|
||||
explícito (mesmo padrão de omissão que o Search, §2.1).
|
||||
|
||||
**`execute()`** detecta o builder da página com `detect_builder()` (estático, público):
|
||||
`is_elementor` (via `_elementor_edit_mode=builder` postmeta) → `'elementor'`; senão
|
||||
`has_blocks($content)` → `'gutenberg'`; senão `'classic'`. Este valor entra no objecto
|
||||
`post` devolvido e é passado ao builder (§3.2).
|
||||
|
||||
### 3.2 `EMCP_Tools_Page_Snapshot` — o builder
|
||||
|
||||
`includes/class-page-snapshot.php`. Duas listas de secções:
|
||||
|
||||
- **`CORE_SECTIONS`** (`post, structure, tokens, responsive, content, seo_lite, warnings`) —
|
||||
sempre computadas em processo, baratas, puras (nenhuma chamada de rede/loopback).
|
||||
- **`HEAVY_SECTIONS`** (`performance, a11y, seo`) — **opt-in** via `include`, cacheadas em
|
||||
transient 15 min (`emcp_snap_{post_id}_{section}`), bypass com `fresh:true`.
|
||||
|
||||
**Achado — a árvore só é lida quando `builder==='elementor'`:** `build()` só chama
|
||||
`$this->data->get_page_data($post_id)` quando `$args['builder']==='elementor'`; para
|
||||
`gutenberg`/`classic` `$elements` fica `array()` vazio, e por isso `structure`, `tokens`,
|
||||
`responsive` e a maior parte de `content` saem **essencialmente vazios** para páginas não-
|
||||
Elementor, apesar de `detect_builder()` os identificar correctamente. `get-page-snapshot` é,
|
||||
na prática, uma ferramenta **Elementor-first**; uma implementação equivalente para Gutenberg
|
||||
exigiria o seu próprio tree-walker sobre `parse_blocks()` — não existe neste build.
|
||||
|
||||
**Secção `performance`** (única heavy section livre no Free): exige `manage_options`
|
||||
(verificação extra dentro do próprio builder, independente do `permission_callback` da
|
||||
ability — defesa em profundidade), delega a `EMCP_Tools_Performance_Analyzer` (doc 07),
|
||||
resultado achatado a `{available, score, grade, recommendations[≤5]}`.
|
||||
|
||||
**Secções `a11y`/`seo`** (Pro): resolvidas via o filtro `emcp_tools_page_snapshot_sections`
|
||||
— quando nada responde ao filtro (build Free), degradam para
|
||||
`{available:false, pro_gated:true}`. **Padrão de "seam" reutilizável numa réplica:** o
|
||||
core Free nunca sabe o que o Pro faz, só declara a forma do buraco (`available`/`pro_gated`)
|
||||
e deixa o filtro preenchê-lo se existir um overlay activo.
|
||||
|
||||
**`seo_lite()`** — leitura gratuita e superficial de SEO: conta H1 a partir de `content`,
|
||||
lê chaves de postmeta conhecidas de Yoast/Rank Math/SEOPress em cascata (primeira não-vazia
|
||||
ganha) para `meta_title`/`meta_description`/`canonical`/`og_image`. Filtro
|
||||
`emcp_tools_page_snapshot_seo_lite` permite a um plugin que **não** guarda SEO em postmeta
|
||||
(ex.: All in One SEO, tabela própria) injectar os valores correctos — mesmo padrão de seam
|
||||
usado noutros pontos do plugin.
|
||||
|
||||
**Helpers puros de árvore (reutilizados por §2 e potencialmente por qualquer outra tool que
|
||||
precise de "entender" uma árvore Elementor sem reescrever o parsing):**
|
||||
- `normalize_tree()` — recursivo, produz `{tree, counts}` com `containers`, `widgets`,
|
||||
`by_widget_type`, `max_depth`, `total_elements`; cada nó da árvore ganha `label` derivado
|
||||
de `element_label()`.
|
||||
- `element_label()` — primeiro campo não-vazio de
|
||||
`_title|title|text|editor|heading_title` nas settings, tags HTML removidas, cortado a 60
|
||||
chars (`snippet()`).
|
||||
- `extract_tokens()` — cores/tipografia globais referenciadas via `__globals__` (regex
|
||||
`globals/colors?id=…` / `globals/typography?id=…`), classes `g-` (de `_css_classes`/
|
||||
`classes` string OU `classes.value` array — dois formatos coexistentes, clássico vs
|
||||
atómico), fontes/cores hex "em uso" (heurística: chave de settings contém `font_family` ou
|
||||
`color` + valor casa `#hex`).
|
||||
- `detect_responsive()` — regex `_(tablet|mobile|laptop|widescreen|mobile_extra|
|
||||
tablet_extra)$` sobre as chaves de settings de cada nó.
|
||||
- `content_stats()` — outline de headings, contagem de palavras, imagens/links/botões,
|
||||
imagens sem alt. **Cobre tanto widgets clássicos como atómicos (Elementor 4.0+)** — os
|
||||
ramos atómicos (`e-heading`/`e-paragraph`/`e-button`/`e-image`) foram adicionados
|
||||
explicitamente com o comentário: *"Atomic (Elementor 4.0) widgets store their content as
|
||||
$$type-wrapped props under different keys than classic widgets, so the classic branches
|
||||
above miss them entirely (**issue #91**). Handle them here."* — um bug real, corrigido,
|
||||
citado no código; qualquer réplica que suporte Elementor 4.0 tem de replicar este
|
||||
desdobramento clássico+atómico em paralelo, não assumir que um cobre o outro.
|
||||
- `warnings()` — 4 cheiros estruturais: `no_h1`, `multiple_h1`, `deep_nesting` (depth≥6),
|
||||
`empty_container` (recursivo `has_empty_container()`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Change-Ledger / Rollback (Transacções "AI-safe")
|
||||
|
||||
Este é, tal como assinalado no pedido, **o subsistema mais valioso a replicar bem** — é a
|
||||
rede de segurança de **todas** as escritas do plugin (Elementor, filesystem, BD directa,
|
||||
posts/CPT, settings, redirects, utilizadores, ACF, media). Quatro classes cooperam:
|
||||
|
||||
```
|
||||
EMCP_Tools_Transaction_Abilities → as 3 tools MCP (list-changes/get-change/rollback-change)
|
||||
EMCP_Tools_Change_Log → o ledger em si (option capado) + o DISPATCHER de rollback
|
||||
EMCP_Tools_Change_Recorder → a FACHADA que cada write-site chama para gravar "antes"
|
||||
EMCP_Tools_Change_Blobs → tabela SQL para before-images grandes fora do option
|
||||
```
|
||||
|
||||
### 4.1 `EMCP_Tools_Transaction_Abilities` — as 3 tools
|
||||
|
||||
`includes/abilities/class-transaction-abilities.php`. Sempre registada (linhas 181-184 do
|
||||
registrar: `// AI-safe transactions — change ledger + rollback (always-on, write
|
||||
foundation).`). `permission_callback` para as 3 = `check_manage()` →
|
||||
`current_user_can('manage_options')`, com o comentário explícito no código: *"the ledger
|
||||
spans admin-grade fs/db targets"* — é o único dos 4 subsistemas deste documento (à parte o
|
||||
Redirect Manager) que exige `manage_options` mesmo para leitura.
|
||||
|
||||
| Tool | Input schema (resumo) | O que faz |
|
||||
|---|---|---|
|
||||
| `list-changes` | `domain?:enum[elementor,filesystem,database]`, `rolled_back?:bool`, `reversible?:bool`, `limit?:int` (def 50) | Lista entradas mais recentes primeiro (`array_reverse`), cada uma com `id, ts, user_login, domain, action, target, summary, rolled_back, reversible, rollback` (o `rollback` devolvido é uma versão "leve" — ver `light_rollback()` abaixo). `reversible` é **derivado**: `!empty(rollback) && empty(rolled_back)`. |
|
||||
| `get-change` | `id*:string`. `required:[id]` | Devolve a entrada **completa** (incluindo o `rollback` ref inteiro, sem strip). |
|
||||
| `rollback-change` | `id*:string`, `force?:bool` (def false) | Desfaz a entrada. `force` salta o "conflict guard" (§4.3). |
|
||||
|
||||
**`light_rollback()`** — usada só por `list-changes` (não por `get-change`): remove
|
||||
`before_rows`/`before`/`inserted_key` do ref de rollback antes de o incluir na resposta —
|
||||
evita que uma listagem de 50 entradas arraste payloads pesados (mesmo já offloadados para
|
||||
blob, o `blob_id` sozinho é leve, mas antes de existirem blobs os `before`/`before_rows`
|
||||
inline podiam ser grandes). **Nota:** o enum declarado no `input_schema` de `domain`
|
||||
(`elementor|filesystem|database`) é **mais estreito** do que os domínios realmente gravados
|
||||
pelo Recorder (`content, settings, redirect, users, acf, media` também existem — ver §4.2) —
|
||||
`execute_list()` faz uma comparação de string simples (`$e['domain']!==$domain`), não valida
|
||||
contra o enum, logo um cliente que ignore o schema declarado e passe `domain:"redirect"`
|
||||
**funciona na mesma**. Inconsistência schema-vs-implementação a corrigir numa réplica (alargar
|
||||
o enum para cobrir todos os domínios reais, ou documentar que o filtro aceita qualquer
|
||||
string).
|
||||
|
||||
### 4.2 `EMCP_Tools_Change_Log` — o ledger + o dispatcher de rollback
|
||||
|
||||
`includes/class-change-log.php`. **O ledger em si não tem tabela SQL própria** — é
|
||||
`get_option('emcp_tools_changelog', [])`, um array PHP serializado, sem versionamento de
|
||||
schema (não precisa: é só um array de linhas leves). `MAX_COUNT=500`, `MAX_BYTES=2097152`
|
||||
(~2 MB) — `cap()` primeiro corta por contagem (`array_slice` às 500 mais recentes), depois
|
||||
itera a apagar a mais antiga (`array_shift`) enquanto o JSON codificado continuar acima do
|
||||
limite de bytes. **Toda linha descartada por `cap()`/`delete()`/`clear()` passa por
|
||||
`forget_blobs()`**, que apaga o `blob_id` referenciado em `EMCP_Tools_Change_Blobs` — sem
|
||||
isto, o offload de before-images pesados (§4.4) acumularia blobs órfãos indefinidamente.
|
||||
|
||||
**A flag de supressão — o mecanismo central que evita recursão:**
|
||||
```php
|
||||
public static $suppress = false; // estática, pública
|
||||
```
|
||||
Quando `true`, `record()` é um no-op imediato (`return ''`). `rollback()` liga-a
|
||||
(`self::$suppress = true`) **antes** de chamar `apply_rollback($rb)` e desliga-a num
|
||||
`finally` **antes** de (a) marcar a entrada como `rolled_back` e (b) gravar a entrada
|
||||
compensatória. **Consequência de design:** qualquer escrita que o próprio `apply_rollback()`
|
||||
provoque (ex.: `rollback_elementor()` chama `EMCP_Tools_Data::save_page_data()`, que
|
||||
internamente também grava no ledger via o Recorder) fica **suprimida** — não gera uma
|
||||
entrada duplicada. Mas a **entrada compensatória do próprio rollback** é gravada
|
||||
DEPOIS do `finally` reactivar `$suppress=false`, logo essa **é** gravada normalmente. O
|
||||
resultado líquido: um `rollback-change` produz exactamente **uma** nova entrada no ledger
|
||||
(`action:'rollback'`), nunca duas nem zero. **Este é o padrão exacto a copiar numa réplica**
|
||||
— uma flag estática global de supressão, ligada só durante a aplicação do efeito colateral
|
||||
do próprio rollback, desligada antes do housekeeping final do próprio rollback.
|
||||
|
||||
**`get()`** é O(n) linear sobre `all()` — sem índice, aceitável até 500 entradas mas não
|
||||
escalaria numa réplica com um teto de retenção maior sem passar a tabela SQL indexada.
|
||||
|
||||
### 4.3 O "conflict guard" — `detect_conflict()`
|
||||
|
||||
Antes de aplicar um rollback (salvo `force:true`), compara `rb['after_hash']` (gravado no
|
||||
momento da escrita original, §4.4) contra `current_hash($rb)` (recalculado **agora**, no
|
||||
momento do rollback). Se diferentes → `WP_Error('conflict', …)`, obrigando o chamador a
|
||||
decidir explicitamente sobre-escrever com `force:true`.
|
||||
|
||||
**`current_hash()` só sabe recalcular 5 dos 15 tipos de rollback:**
|
||||
```
|
||||
elementor-data → hash_elementor(post_id)
|
||||
file-backup/file-create → hash_file(target_path)
|
||||
option → hash_options(option_keys) OU hash_option(option) [legacy single-key]
|
||||
post-fields → hash_post(post_id)
|
||||
meta-before-image → hash_meta(object, id, meta_keys)
|
||||
default (tudo o resto) → '' → tratado como "não é possível recalcular, não bloquear"
|
||||
```
|
||||
**Consequência directa e não-óbvia:** rollbacks dos tipos `db-before-image`,
|
||||
`post-create`, `post-restore`, `attachment-delete`, `user-create`, `user-fields`,
|
||||
`acf-fields`, `redirect-row` **nunca disparam o conflict guard** — avançam sempre como se
|
||||
`force:true` estivesse implícito, porque `record_db()`/`record_post_create()`/etc. nunca
|
||||
gravam um `after_hash` correspondente (confirmado por leitura de `class-change-recorder.php`
|
||||
— nenhuma dessas chamadas `record_*()` inclui `rb['after_hash']=…`). O único freio para
|
||||
esses tipos são as verificações **internas** de cada `rollback_*()` (ex.: `user-create`
|
||||
recusa apagar um utilizador que entretanto ganhou `manage_options`; `post-create` devolve
|
||||
`true` silenciosamente se o post já não existir). **Numa réplica que queira o guard
|
||||
uniforme**, seria preciso estender `hash_*()`+`current_hash()` para cobrir também DB rows
|
||||
(hash das colunas-chave), criação/eliminação de posts/utilizadores/anexos, etc. — hoje é uma
|
||||
protecção parcial, não total, apesar do nome "AI-safe transactions" sugerir cobertura
|
||||
completa.
|
||||
|
||||
### 4.4 `EMCP_Tools_Change_Recorder` — a fachada de gravação ("o que gravar, quando")
|
||||
|
||||
`includes/class-change-recorder.php`. **Contrato universal:** cada write-site (uma ability
|
||||
de escrita, fora do âmbito destes ficheiros) é responsável por **capturar o estado "antes"
|
||||
ele próprio, antes de efectuar a mutação**, e passar esse "antes" a um dos métodos
|
||||
`record_*()` do Recorder. O Recorder **não lê o estado anterior por iniciativa própria** —
|
||||
excepto os dois helpers de snapshot explícitos (`snapshot_attachment()`/`snapshot_post()`),
|
||||
documentados com o aviso literal *"MUST be called BEFORE wp_delete_attachment"* /
|
||||
implícito para `wp_delete_post` — ou seja, o padrão é sempre: **1) o chamador lê/captura o
|
||||
antes (directamente ou via `snapshot_*()`), 2) o chamador efectua a mutação, 3) o chamador
|
||||
chama `record_*()` com o antes capturado.**
|
||||
|
||||
**Os 13 métodos `record_*()` e o que cada um espera como "antes":**
|
||||
|
||||
| Método | Domain/action gravado | "Antes" esperado do chamador | Estampa `after_hash`? |
|
||||
|---|---|---|---|
|
||||
| `record_elementor(post_id, before_tree, summary, target)` | `elementor` / `page-edit` | árvore `_elementor_data` anterior completa | sim, `hash_elementor()` |
|
||||
| `record_db(entry)` | livre (o chamador constrói a entrada inteira; só `rollback.before_rows` é tratado especialmente) | `entry['rollback']['before_rows']` (linhas SQL antes) | **não** |
|
||||
| `record_file(entry, written_abs)` | livre (chamador constrói) | ref `file-backup`/`file-create` já montado pelo chamador | sim, `hash_file($written_abs)` |
|
||||
| `record_post_fields(post_id, before, summary, target, domain='content', action='update-post')` | configurável | `{fields, meta, terms}` parcial (só o que a escrita pode mudar) | sim, `hash_post()` |
|
||||
| `record_post_create(post_id, summary, target)` | `content` / `create-post` | nada (undo = apagar o post criado) | não |
|
||||
| `record_post_delete(post_id, snapshot, forced, summary, target)` | `content` / `delete-post` | `snapshot_post()` **só se `$forced`** (trash usa `mode:'untrash'`, sem snapshot) | não |
|
||||
| `record_options(before_map, summary, target, domain='settings', action='update-settings')` | configurável | `{option => valor anterior \| '__ABSENT__'}` | sim, `hash_options()` |
|
||||
| `record_redirect(action, before, summary, target)` | `redirect` / create\|update\|delete | `{id}` (create) ou `{row:{...}}` (update/delete) — delega ao applier do Store (§1.3) | não |
|
||||
| `record_meta(object, id, before_map, summary, target, domain='content', action='update')` | configurável | `{meta_key => valor anterior}` (post OU term) | sim, `hash_meta()` |
|
||||
| `record_user_create(user_id, summary, target)` | `users` / `create-user` | nada (undo = apagar utilizador) | não |
|
||||
| `record_user_fields(user_id, before, summary, target)` | `users` / `update-user` | `{campo wp_update_user => valor anterior}` | não |
|
||||
| `record_acf_fields(acf_target, before, summary, target)` | `acf` / `update-fields` | `{field_key => valor bruto anterior}` | não |
|
||||
| `record_attachment_delete(snapshot, att_id, summary, target)` | `media` / `delete-media` | `snapshot_attachment($att_id)` (chamado **antes** de `wp_delete_attachment`) | não |
|
||||
|
||||
**`attach_before($rb, $heavy)`** — decide inline-vs-blob por **tamanho do JSON codificado**:
|
||||
se `strlen(wp_json_encode($heavy)) > BLOB_THRESHOLD` (4096 bytes) **e** a classe de blobs
|
||||
existe, offload para `EMCP_Tools_Change_Blobs::put($heavy)` e o `rb` guarda só `blob_id`;
|
||||
senão, faz `array_merge($rb, $heavy)` inline. Chamado por `record_elementor`, `record_db`,
|
||||
`record_post_fields`, `record_post_delete` (modo `forced`), `record_options`,
|
||||
`record_meta`, `record_user_fields`, `record_acf_fields`, `record_attachment_delete` — ou
|
||||
seja, **quase todos** os tipos que carregam um "antes" estruturado; os que não têm "antes"
|
||||
(criações) não precisam deste passo.
|
||||
|
||||
**A flag `partial`** (mencionada nos outputs de `rollback-change`) **não é computada pelo
|
||||
Recorder nem pelo Change_Log** — é um campo que o **chamador de `record_db()`** deve
|
||||
definir ele próprio dentro do `rollback` que constrói, quando limita quantas `before_rows`
|
||||
capturou (ex.: um `update-rows`/`delete-rows` que tope a captura a N linhas por segurança de
|
||||
memória). O Recorder e o Log limitam-se a propagá-lo verbatim até ao output de
|
||||
`rollback-change`. **Contrato implícito para qualquer nova write-tool numa réplica:** se
|
||||
limitares as linhas "antes" capturadas, marca `rollback.partial=true` tu próprio.
|
||||
|
||||
### 4.5 O dispatcher de rollback — `apply_rollback()`, 15 tipos
|
||||
|
||||
`EMCP_Tools_Change_Log::apply_rollback($rb)`. **Primeiro**, resolve um `blob_id` se
|
||||
existir (`EMCP_Tools_Change_Blobs::get()`, funde no `$rb` — devolve `WP_Error('blob_missing')`
|
||||
se o blob já não existir, ex. por ter sido varrido por `prune_before()`), **depois** despacha
|
||||
por `$rb['type']`:
|
||||
|
||||
| `type` | Applier | Lógica de reversão | Onde vive |
|
||||
|---|---|---|---|
|
||||
| `elementor-data` | `rollback_elementor()` | Regrava a árvore anterior via `EMCP_Tools_Data::save_page_data()` | Change_Log |
|
||||
| `file-backup` | `rollback_file_restore()` | `copy(backup, target)`, confinado a ABSPATH via `EMCP_Tools_Filesystem_Guard::resolve_path()` (doc 07) | Change_Log |
|
||||
| `file-create` | `rollback_file_delete()` | `unlink()` do ficheiro criado, confinado a ABSPATH; já-ausente devolve `true` silenciosamente | Change_Log |
|
||||
| `db-before-image` | `rollback_db()` | `update`: **recusa se `key_cols` vazio** (evita `$wpdb->update()` sem WHERE, que tocaria todas as linhas — regra de segurança dura, não contornável mesmo com `force`); `delete`: reinsere cada `before_rows`; `insert`: apaga por `inserted_key` | Change_Log |
|
||||
| `meta-before-image` | `rollback_meta()` | post OU term meta; valor vazio (`''`/`[]`/`null`) → apaga a chave, senão actualiza | Change_Log |
|
||||
| `post-fields` | `rollback_post_fields()` | Restaura `fields` (wp_update_post), `meta` (sentinela `'__DELETE__'` apaga a chave), `terms` (`wp_set_object_terms`, `append=false`) | Change_Log |
|
||||
| `post-create` | `rollback_post_create()` | `wp_delete_post($id, true)` — force delete; já-ausente devolve `true` | Change_Log |
|
||||
| `post-restore` | `rollback_post_restore()` | `mode:'untrash'` → `wp_untrash_post()`; `mode:'reinsert'` → reinsere do snapshot completo (post+meta+terms), **reaproveita o `id` original se estiver livre** (`import_id`) | Change_Log |
|
||||
| `option` | `rollback_option()` | Por nome: `'__ABSENT__'` → `delete_option()`, senão `update_option()` | Change_Log |
|
||||
| `attachment-delete` | `rollback_attachment_delete()` | Reinsere o post do anexo, restaura toda a meta (`add_post_meta` por valor, não substitui), copia os ficheiros da "lixeira" (`emcp-originals/trash/{att_id}/`) de volta aos caminhos originais | Change_Log |
|
||||
| `user-create` | `rollback_user_create()` | `wp_delete_user()` — **recusa se o utilizador entretanto ganhou `manage_options`** (`rollback_refused`, não `rollback_failed` — código de erro distinto para "recusado por segurança" vs "falhou tecnicamente") | Change_Log |
|
||||
| `user-fields` | `rollback_user_fields()` | `wp_update_user(['ID'=>id, ...before])` | Change_Log |
|
||||
| `acf-fields` | `rollback_acf_fields()` | `update_field($field_key, $value, $target)` por campo — reversão correcta de campos simples E complexos porque passa pela API do ACF, não escreve postmeta bruto | Change_Log |
|
||||
| `redirect-row` | delega a `EMCP_Tools_Redirect_Store::rollback($rb)` | Ver §1.3 | **Redirect_Store** (não Change_Log!) |
|
||||
| *(default)* | — | `WP_Error('unknown_rollback')` | Change_Log |
|
||||
|
||||
**Ponto de arquitectura chave para a réplica:** 14 dos 15 appliers vivem centralizados em
|
||||
`Change_Log`, mas o `redirect-row` delega para a classe de domínio (`Redirect_Store`). É a
|
||||
**única** excepção — mostra que o dispatcher central é desenhado para permitir extensão por
|
||||
delegação: um novo domínio (numa réplica, ex. um domínio "SEO" ou "menu") pode manter o seu
|
||||
próprio applier de rollback junto do resto da sua lógica de domínio, e o `apply_rollback()`
|
||||
central só precisa de um `case` de uma linha a delegar, sem ter de concentrar toda a lógica
|
||||
num único ficheiro gigante. **Recomenda-se replicar este padrão de delegação por omissão**,
|
||||
não a centralização usada nos outros 14 casos (que provavelmente só não foram refactorizados
|
||||
por serem código mais antigo, escritos antes do padrão de delegação ter emergido).
|
||||
|
||||
---
|
||||
|
||||
## 5. Content Mirror (export/restore git-friendly)
|
||||
|
||||
**Classe de abilities:** `EMCP_Tools_Content_Mirror_Abilities`
|
||||
(`includes/abilities/class-content-mirror-abilities.php`) — sempre registada (linhas 191-194
|
||||
do registrar: `// Content mirror — export/restore page content as git-trackable files
|
||||
(always-on).`). `permission_callback` = `check_permission()` → `edit_posts` (mais permissivo
|
||||
que o ledger, alinhado com Search/Snapshot).
|
||||
|
||||
**Relação com o ledger** (do header do ficheiro `class-content-mirror.php`): *"Complements
|
||||
AI-safe transactions: transactions are an in-DB recent-change ledger + rollback; the mirror
|
||||
is durable, diffable, file-based history."* — são **mecanismos paralelos e independentes**,
|
||||
não um substituto do outro: o ledger cobre "a última hora de escritas, reversível ao nível
|
||||
da linha"; o mirror cobre "snapshot completo e legível em qualquer altura, feito para diff em
|
||||
git". **O plugin nunca corre `git` a si próprio** — só escreve ficheiros; cabe ao utilizador
|
||||
(ou a um CI) fazer `git add`/`commit`.
|
||||
|
||||
### 5.1 Tools
|
||||
|
||||
| Tool | Input schema (resumo) | O que faz |
|
||||
|---|---|---|
|
||||
| `export-content` | `post_id?:int` (omitido = exporta todos) | Exporta um post/template Elementor, ou todos, para JSON em disco. |
|
||||
| `restore-content` | `post_id*:int`. `required:[post_id]` | Regrava o `_elementor_data` do post a partir do ficheiro mirror existente (undo baseado em ficheiro). |
|
||||
| `list-content-exports` | `{}` (sem propriedades) | Lista os ficheiros de mirror em disco: `{file, id, type, title, exported_at}`. |
|
||||
|
||||
### 5.2 `EMCP_Tools_Content_Mirror` — storage em disco
|
||||
|
||||
`includes/class-content-mirror.php`. **Sem tabela SQL nem custom post type** — armazenamento
|
||||
puro em ficheiro, sob `wp-content/uploads/emcp-content-mirror/` (`MIRROR_DIR` +
|
||||
`wp_upload_dir()['basedir']`). Nome de ficheiro determinístico e legível:
|
||||
`{type}-{id}-{slug-sanitizado}.json` (`type` = `template` se `post_type===elementor_library`,
|
||||
senão `page`; slug passado por `preg_replace('/[^A-Za-z0-9]+/','-', …)` + trim de hífens).
|
||||
|
||||
`build_export()` (função pura): `{id, type, slug, title, elementor_data, exported_at}`.
|
||||
`export_post()`: obtém `elementor_data` via `EMCP_Tools_Data::get_page_data()` (try/catch —
|
||||
falha silenciosamente para `array()` se a leitura Elementor rebentar), grava com
|
||||
`JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` — **formatação
|
||||
deliberadamente amigável a diff de git**, não a formatação compacta que outras partes do
|
||||
plugin usam para `wp_json_encode()` de opções/BD. No primeiro export de sempre, cria também
|
||||
um `README.txt` explicativo no directório, e o comentário no código é explícito: *"Do NOT
|
||||
ignore json — this dir is meant to be committed."*
|
||||
|
||||
`restore_post()`: lê o JSON, valida `elementor_data` presente e é array, chama
|
||||
`EMCP_Tools_Data::save_page_data()` — a mesma chamada usada por `rollback_elementor()` no
|
||||
ledger (§4.5). **Consequência prática:** um `restore-content` gera, ele próprio, uma nova
|
||||
entrada no ledger (via o Recorder chamado dentro de `save_page_data()`, fora do âmbito destes
|
||||
ficheiros mas implícito pela partilha do mesmo método de escrita) — os dois subsistemas
|
||||
interoperam: pode fazer-se `restore-content` e depois, se o resultado não agradar,
|
||||
`rollback-change` a esse mesmo restore através do ledger.
|
||||
|
||||
`export_all()`: varre **só posts `publish`** (`page`/`post` com `_elementor_edit_mode=builder`)
|
||||
+ **só templates `publish`** — mais restritivo que `export_post()` (que não impõe status
|
||||
quando o `post_id` é explícito) e mais restritivo que o `rebuild()` do Search Index (§2.2,
|
||||
que indexa `draft/pending/private/future` também). Três políticas de "que status contam"
|
||||
diferentes dentro do mesmo plugin, cada uma justificável pelo seu propósito (indexar
|
||||
rascunhos ajuda a pesquisa; espelhar só o publicado evita ruído no histórico git de
|
||||
conteúdo ainda não decidido) — mas vale a pena decidir isto **conscientemente** numa réplica,
|
||||
não por acidente de cópia de código.
|
||||
|
||||
**Auto-export opt-in:** `init()` liga `save_post:40` + `before_delete_post:10`, mas
|
||||
`on_save_post()`/`on_delete_post()` só actuam quando `enabled()` →
|
||||
`get_option('emcp_tools_content_mirror_enabled')==='1'` — **desligado por omissão** (a UI de
|
||||
admin tem o toggle em "EMCP Tools → Tools", fora do âmbito destes ficheiros). O `on_delete_post`
|
||||
corre em `before_delete_post` (não `deleted_post`) porque só precisa do `post_type`/`post_name`
|
||||
para calcular o nome do ficheiro a apagar — não precisa que o post já tenha desaparecido da
|
||||
BD.
|
||||
|
||||
---
|
||||
|
||||
## 6. `EMCP_Tools_Url_Guard` — serviço SSRF partilhado (fora do agrupamento temático)
|
||||
|
||||
`includes/class-url-guard.php`. Não pertence a nenhum dos 4 subsistemas acima — documentado
|
||||
aqui só porque estava na lista de ficheiros desta tarefa. Duas camadas de validação
|
||||
distintas, adicionadas em versões diferentes:
|
||||
|
||||
**Camada 1 — `is_safe_remote_url()` + `safe_download()`** (desde 1.9.1, usada pelo sideload de
|
||||
imagem/SVG, doc 09): valida esquema http(s), `wp_http_validate_url()` (bloqueia a maioria dos
|
||||
ranges RFC1918/loopback), depois **complementa** com `filter_var($ip,
|
||||
FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE)` sobre o resultado de
|
||||
`gethostbyname()` — o comentário no código é explícito sobre a lacuna que isto tapa:
|
||||
*"`wp_http_validate_url()` rejects most private RFC1918 ranges, loopback, and non-80/443/8080
|
||||
ports — but NOT the link-local 169.254.0.0/16 range (which includes the cloud-metadata
|
||||
endpoint 169.254.169.254), and it does not cover IPv6 internal addresses."* `safe_download()`
|
||||
adiciona `reject_unsafe_urls:true` + capa `redirection` a no máximo 2 saltos via um filtro
|
||||
`http_request_args` temporário (adicionado e removido à volta da chamada), para que
|
||||
`download_url()` (que por si só **não** valida saltos de redirect) revalide cada hop.
|
||||
|
||||
**Camada 2 — `validate()` + `ip_is_blocked()`** (desde 3.2.0, para uma tool `web_fetch` de AI
|
||||
Chat, Pro, fora deste build): gate mais estrito, com resolver **injectável**
|
||||
(`?callable $resolver`) — desenhado explicitamente para ser 100% testável sem rede real. Além
|
||||
de bloquear ranges privados/loopback/link-local (IPv4 **e** IPv6, incluindo o caso
|
||||
IPv4-mapped-em-IPv6 `::ffff:127.0.0.1`, desembrulhado via `inet_pton`), bloqueia também
|
||||
**credenciais na URL** (`user:pass@host`) e restringe a **só as portas 80/443**
|
||||
(`ALLOWED_PORTS`). Resolve **A e AAAA** (não só o primeiro A record, ao contrário da Camada
|
||||
1) e exige que **todos** os IPs resolvidos sejam públicos — comentário explícito: *"a host
|
||||
publishing one public and one internal record must not slip through."* **Falha fechado**:
|
||||
qualquer IP não parseável em `in_any_cidr()` é tratado como bloqueado.
|
||||
|
||||
**Limitação documentada e não resolvida (TOCTOU):** o comentário do método é directo sobre
|
||||
isto: *"WordPress's HTTP API connects by hostname, so a TOCTOU window remains between this
|
||||
check and the TCP connect (DNS rebinding). Re-validating every redirect hop and using a short
|
||||
timeout narrow it; closing it entirely needs `CURLOPT_RESOLVE` pinning."* — ou seja, o autor
|
||||
sabe que este guard não é 100% hermético contra DNS rebinding sofisticado, e diz exactamente
|
||||
qual seria a correcção completa (`CURLOPT_RESOLVE`), sem a ter implementado.
|
||||
|
||||
---
|
||||
|
||||
## Blueprint para réplica
|
||||
|
||||
**Copiar quase 1:1 (o valor está no design, não no código específico):**
|
||||
- **O mecanismo `$suppress` do ledger** (§4.2) — a peça mais elegante de todo este
|
||||
subconjunto. Uma flag estática global, ligada só durante o efeito colateral do próprio
|
||||
rollback, desligada antes de gravar a entrada compensatória. Sem isto, qualquer rollback
|
||||
que reutilize a mesma via de escrita normal (ex. `save_page_data()`) criaria ruído
|
||||
recursivo no ledger.
|
||||
- **O padrão de offload para blob por tamanho** (`attach_before()`, §4.4) — decidir
|
||||
inline-vs-out-of-band por `strlen(json_encode())` comparado a um threshold simples (4 KB)
|
||||
é suficiente e evita over-engineering; não vale a pena um sistema de chunking mais
|
||||
complexo para este caso de uso.
|
||||
- **O padrão de delegação do `redirect-row` applier** (§4.5) — cada domínio novo deve poder
|
||||
manter o seu próprio applier de rollback junto da sua lógica de domínio, com o dispatcher
|
||||
central a delegar por um `case` de uma linha, em vez de forçar tudo para um ficheiro
|
||||
central gigante (como aconteceu com os outros 14 tipos, provavelmente por acumulação
|
||||
histórica mais do que por escolha deliberada).
|
||||
- **A reutilização de `EMCP_Tools_Page_Snapshot`'s helpers puros pelo Search Index** (§2.2) —
|
||||
nunca duplicar o parsing de árvore Elementor entre dois subsistemas que ambos precisam
|
||||
dela; um só "tree walker" alimenta snapshot E indexação.
|
||||
- **O padrão "seam" via `apply_filters()`** repetido em `emcp_tools_page_snapshot_sections`,
|
||||
`emcp_tools_page_snapshot_seo_lite`, `emcp_tools_search_rerank` — free core declara a forma
|
||||
do output e um valor por omissão sensato (`{available:false, pro_gated:true}` ou o
|
||||
resultado léxico simples); um overlay Pro/plugin externo pode substituir sem o core
|
||||
precisar de saber que ele existe.
|
||||
- **A dupla cobertura clássico+atómico em `content_stats()`** (issue #91, §3.2) — qualquer
|
||||
função que percorra árvores Elementor numa réplica com suporte a 4.0 tem de tratar
|
||||
explicitamente os dois formatos de settings (`$$type`-wrapped vs directo), nunca assumir
|
||||
que um cobre o outro.
|
||||
|
||||
**Simplificar:**
|
||||
- **O ranking léxico (`Search_Ranker`)** pode começar mais simples do que este TF-IDF
|
||||
aproximado — mesmo uma pontuação por contagem de termos com boost de título já cobriria
|
||||
90% do valor para um MVP; a fórmula IDF tipo-BM25 aqui só compensa em corpora maiores do
|
||||
que uma réplica inicial provavelmente terá. Manter o seam de rerank desde o dia 1, mesmo
|
||||
que a v1 seja trivial.
|
||||
- **A coluna `tokens` morta no Search Index** (§2.2) — não replicar; se se quiser um índice
|
||||
mais eficiente do que retokenizar tudo a cada pesquisa, ir directo para `FULLTEXT` MySQL
|
||||
sobre `content`/`title`, ou uma tabela invertida `(termo, object_type, object_id, tf)`
|
||||
própria — não guardar tokens concatenados numa coluna que ninguém consulta.
|
||||
- **O `conflict guard` parcial** (§4.3, só 5 de 15 tipos suportados) — decidir
|
||||
deliberadamente se vale a pena estender a todos os tipos (mais seguro, mais trabalho) ou
|
||||
manter parcial e documentar claramente ao utilizador que "conflito" só é detectado para
|
||||
certos tipos de escrita.
|
||||
|
||||
**Deixar de fora / decidir explicitamente antes de copiar:**
|
||||
- **`match_type` e `ignore_query` no Redirect Manager** (§1.1/§1.3) — campos "de intenção
|
||||
futura" nunca lidos pelo matcher real. Ou implementar o comportamento prometido, ou não os
|
||||
incluir no schema até o fazer.
|
||||
- **As três políticas diferentes de "que status conta"** entre `Search_Index::rebuild()`
|
||||
(todos os status), `Content_Mirror::export_all()` (só publish) e o `export_post()`
|
||||
individual (qualquer status) — cada uma faz sentido isolada mas o conjunto não foi
|
||||
desenhado como um todo coerente; numa réplica, escolher conscientemente por que motivo
|
||||
cada subsistema difere.
|
||||
- **A inconsistência de `meta.annotations` declarado** — Redirect Manager declara sempre
|
||||
`meta` com anotações explícitas; Search/Snapshot/Transactions não declaram nada. Uma
|
||||
réplica deve escolher **um** padrão e aplicá-lo a todas as abilities sem excepção (a
|
||||
camada `emcp_tools_register_ability()` já documentada em `00-ARQUITECTURA.md` §5 seria o
|
||||
sítio certo para impor isto por omissão, em vez de confiar em cada classe de abilities
|
||||
lembrar-se de o declarar).
|
||||
- **O enum de `domain` desalinhado com os domínios reais gravados** em `list-changes` (§4.1)
|
||||
— corrigir antes de copiar, é um bug de schema trivial de evitar desde o início.
|
||||
|
||||
## Fonte
|
||||
|
||||
Leitura directa (19-08-2026) de:
|
||||
`includes/abilities/class-redirect-abilities.php`,
|
||||
`includes/redirects/class-redirect-handler.php`,
|
||||
`includes/redirects/class-redirect-store.php`,
|
||||
`includes/class-url-guard.php`,
|
||||
`includes/abilities/class-search-abilities.php`,
|
||||
`includes/class-search-index.php`,
|
||||
`includes/class-search-ranker.php`,
|
||||
`includes/abilities/class-snapshot-abilities.php`,
|
||||
`includes/class-page-snapshot.php`,
|
||||
`includes/abilities/class-transaction-abilities.php`,
|
||||
`includes/class-change-log.php`,
|
||||
`includes/class-change-recorder.php`,
|
||||
`includes/class-change-blobs.php`,
|
||||
`includes/abilities/class-content-mirror-abilities.php`,
|
||||
`includes/class-content-mirror.php`.
|
||||
|
||||
Gating condition do Redirect Manager confirmada por grep directo a
|
||||
`includes/abilities/class-ability-registrar.php` (linhas 161-195, incluindo os comentários
|
||||
"always-on" para os outros 3 subsistemas). Contexto de arranque/contrato de registo herdado
|
||||
de `00-ARQUITECTURA.md` e de `skill://emcp-tools` (não relidos linha a linha nesta tarefa,
|
||||
usados só como pano de fundo já validado em sessões anteriores).
|
||||
@@ -0,0 +1,336 @@
|
||||
# 06 — Sandbox de PHP Snippets e Infraestrutura de Widgets/Blocos Custom
|
||||
|
||||
Este documento mapeia o subsistema "Sandbox" do EMCP Tools: a funcionalidade Free de PHP Snippets criados por agentes de IA com aprovação humana obrigatória, o contrato de export/import cross-artefacto (`EMCP_Tools_Sandbox_Artifact`), a infraestrutura de caminhos partilhada, e o Widget Builder (Pro) — cuja parte MCP está **ausente** desta build Free, deixando apenas a camada de armazenamento/admin acessível a partir do wp-admin. Documenta-se também `class-mcpb-builder.php`, cujo nome sugere parentesco com o Widget/Block Builder mas que é, na realidade, uma feature totalmente distinta (gerador de bundle Claude Desktop `.mcpb`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Ability: `EMCP_Tools_PHP_Snippet_Abilities`
|
||||
|
||||
**Ficheiro:** `includes/abilities/class-php-snippet-abilities.php`
|
||||
|
||||
**Condição de registo** (`class-ability-registrar.php`, linhas ~416-421):
|
||||
```php
|
||||
// PHP Snippet abilities (Sandbox) — free, capability-gated, no Elementor.
|
||||
if ( class_exists( 'EMCP_Tools_PHP_Snippet_Abilities' ) ) {
|
||||
$php_snippets = new EMCP_Tools_PHP_Snippet_Abilities();
|
||||
$php_snippets->register();
|
||||
...
|
||||
}
|
||||
```
|
||||
A classe existe **incondicionalmente** na build Free — não há gate de Pro nem de Elementor. O gate real é feito **por-tool** via `permission_callback`, apoiado em duas capabilities WordPress nativas: `manage_options` (leitura) e `manage_options` + `unfiltered_html` (escrita — as mesmas capabilities que já permitem a um utilizador editar código de plugin).
|
||||
|
||||
| Ability | Input (resumo) | O que faz | Permission callback | readonly / destructive |
|
||||
|---|---|---|---|---|
|
||||
| `validate-php-snippet` | `code` (string, obrigatório) | Verifica estaticamente o código SEM o guardar nem correr: confirma que faz *parse* e depois corre o scanner de segurança (execução de código, shell, escritas de ficheiro, rede, ofuscação, SQL destrutivo). Devolve relatório `{valid, safe, parse_error, findings[]}`. Pensado para iterar antes de `create-php-snippet`. | `check_read_permission` → `manage_options` | readonly=true, destructive=false, idempotent=true |
|
||||
| `create-php-snippet` | `title?`, `code` (obrigatório), `context?` (enum `shortcode`\|`hook`\|`both`), `hook?`, `priority?` | Cria o snippet como **DRAFT INACTIVO**. Nunca corre. Valida primeiro; rejeita com `invalid_php` (erro de parse) ou `unsafe_php` (finding crítico) devolvendo o relatório de validação para o agente corrigir. | `check_edit_permission` → `manage_options` AND `unfiltered_html` | readonly=false, destructive=false, idempotent=false |
|
||||
| `update-php-snippet` | `snippet_id` (obrigatório) + campos de `create-php-snippet` (parciais) | Actualiza código/config. Re-valida (mesmas rejeições). Se o snippet já estiver activo, **recompila** o executável em disco; se a recompilação falhar, **degrada automaticamente para draft**. A activação continua a exigir um passo humano separado. | `check_edit_permission` | readonly=false, destructive=false, idempotent=false |
|
||||
| `get-php-snippet` | `snippet_id` (obrigatório) | Devolve o registo completo: código, status (draft/active), contexto de execução, shortcode gerado, e o último relatório de validação. | `check_read_permission` | readonly=true, idempotent=true |
|
||||
| `list-php-snippets` | `status?` (enum `active`\|`draft`\|`any`) | Lista snippets com status, contexto e shortcode. Devolve `{count, snippets[]}`. | `check_read_permission` | readonly=true, idempotent=true |
|
||||
| `delete-php-snippet` | `snippet_id` (obrigatório) | Apaga permanentemente o snippet (post CPT + ficheiro sandbox, se existir). | `check_edit_permission` | readonly=false, **destructive=true**, idempotent=false |
|
||||
|
||||
**Decisão de design central (citação directa do topo do ficheiro):** *"Lets an AI agent author, validate, read, and manage PHP snippets — but NEVER run them. There is intentionally no 'activate' tool: a snippet created via MCP is an inactive draft until a human administrator reviews it and activates it in the Sandbox admin screen."*
|
||||
|
||||
**Helper `normalize_write_result()`:** transforma um `WP_Error` de rejeição de validação (`invalid_php`/`unsafe_php`) numa resposta estruturada `{success:false, reason, validation}` — em vez de um erro opaco, o agente recebe o relatório de findings completo para poder corrigir o código e tentar de novo. Em sucesso, acrescenta sempre uma `note` a lembrar que o draft precisa de aprovação de um administrador.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ability: `EMCP_Tools_Sandbox_Cloud_Abilities`
|
||||
|
||||
**Ficheiro:** `includes/abilities/class-sandbox-cloud-abilities.php`
|
||||
|
||||
**Condição de registo** (`class-ability-registrar.php`, linhas ~429-434):
|
||||
```php
|
||||
// Sandbox cloud export/import (free; operates over the bundle contract).
|
||||
if ( class_exists( 'EMCP_Tools_Sandbox_Cloud_Abilities' ) ) {
|
||||
$cloud = new EMCP_Tools_Sandbox_Cloud_Abilities();
|
||||
$cloud->register();
|
||||
...
|
||||
}
|
||||
```
|
||||
Sempre registada na build Free (ao contrário do Widget Builder, esta classe **não** se autoguarda por Pro). O gate por-`kind` acontece dentro de `resolve_artifact()`, não na classe.
|
||||
|
||||
| Ability | Input (resumo) | O que faz | Permission callback | readonly / destructive |
|
||||
|---|---|---|---|---|
|
||||
| `export-sandbox-artifact` | `kind` (enum `block`\|`widget`\|`snippet`, obrigatório), `id` (int, obrigatório) | Exporta um artefacto de sandbox como bundle portátil, verificado por checksum, pronto para partilha/sincronização cloud. Devolve `{bundle: object}`. | `current_user_can('manage_options')` | readonly=true, destructive=false, idempotent=true |
|
||||
| `import-sandbox-artifact` | `bundle` (object, obrigatório — produzido por `export-sandbox-artifact`) | Importa um bundle como um **novo draft local**. O bundle é validado (versão de schema, checksum) antes de qualquer escrita. Devolve `{id: int}`. | `current_user_can('manage_options')` | readonly=false, destructive=false, idempotent=false |
|
||||
|
||||
**Resolução de artefacto (`resolve_artifact($kind)`):**
|
||||
```php
|
||||
switch ( $kind ) {
|
||||
case 'block': return class_exists('EMCP_Tools_Block_Store') ? EMCP_Tools_Block_Store::instance() : null; // Pro-only, ausente nesta build
|
||||
case 'widget': return new EMCP_Tools_Widget_Bundle_Adapter(); // sempre disponível
|
||||
case 'snippet': return new EMCP_Tools_Snippet_Bundle_Adapter(); // sempre disponível
|
||||
default: return null;
|
||||
}
|
||||
```
|
||||
Quando `resolve_artifact()` devolve `null` para um `kind` válido (`block`), a ability devolve um `WP_Error` `pro_required` em vez de fatal — **falha limpa**. Nota importante: mesmo com `kind=widget` resolúvel (o adapter existe sempre), a operação real de import ainda pode falhar internamente com `WP_Error('forbidden', ...)` dentro de `EMCP_Tools_Widget_Store::create()` se a licença Pro não estiver activa — o gate real vive na *store*, não no resolver.
|
||||
|
||||
---
|
||||
|
||||
## 3. Serviços de suporte
|
||||
|
||||
### 3.1 `EMCP_Tools_PHP_Snippet_Store` — `includes/class-php-snippet-store.php`
|
||||
|
||||
**Storage:** CPT privado `emcp_php_snippet` (`public=false`, `show_in_rest=false`, `capability_type=page`, `map_meta_cap=true`).
|
||||
|
||||
**Meta keys:** `_emcp_snippet_code` (código raw, `wp_slash()`-eado), `_emcp_snippet_context`, `_emcp_snippet_hook`, `_emcp_snippet_priority`, `_emcp_snippet_validation` (JSON do relatório), `_emcp_snippet_hash` (sha256 do ficheiro compilado), `_emcp_snippet_error`.
|
||||
|
||||
**Post status = flag de activação:** `publish` = activo, `draft` = inactivo. Este é o único mecanismo de "ligar/desligar" um snippet.
|
||||
|
||||
**Ficheiro em disco:** `wp-content/emcp-sandbox/snippets/{id}.php` — só existe enquanto o snippet está **activo**. Um draft não tem artefacto executável em disco. O ficheiro é gerado por `write_executable()`: o código é despido de tags PHP (`strip_tags()`), embrulhado numa função única (`emcp_php_snippet_{id}()`), e o resultado final passa por um `token_get_all(..., TOKEN_PARSE)` de segurança (guarda final antes de escrever).
|
||||
|
||||
**Manifest:** `wp-content/emcp-sandbox/snippets-manifest.json` — array de `{post_id, func, php_path, hash, context, hook, priority}` apenas dos snippets `publish`. Reconstruído (`rebuild_manifest()`) após qualquer create/update/set_status/delete/mark_error.
|
||||
|
||||
**Permissões:**
|
||||
- `can_edit()`: `manage_options` AND `unfiltered_html`
|
||||
- `can_read()`: `manage_options`
|
||||
|
||||
**Fluxo CRUD:**
|
||||
- `create_draft()` — sempre cria em `draft`; valida primeiro e rejeita com `invalid_php`/`unsafe_php` (WP_Error com o relatório em `error_data['validation']`).
|
||||
- `update()` — re-valida; se o snippet já estava activo, chama `write_executable()` de novo; se a escrita falhar, **degrada automaticamente para draft** e regista o erro.
|
||||
- **`set_status('active'|'draft')`** — **é o portão de aprovação humana**. Nunca é chamado pelas MCP abilities (só pelo handler AJAX do admin). Ao activar: re-valida (bloqueia com `activation_blocked` se inválido/inseguro), escreve o executável, muda para `publish`. Ao desactivar: apaga o ficheiro, apaga o hash, muda para `draft`.
|
||||
- `mark_error()` — chamado pelo *loader* quando um snippet crasha em runtime: desactiva automaticamente, regista o erro, reconstrói o manifest.
|
||||
- `uninstall_cleanup()` — apaga todos os posts + o `manifest.json` no desinstalar do plugin.
|
||||
|
||||
### 3.2 `EMCP_Tools_PHP_Snippet_Loader` — `includes/class-php-snippet-loader.php`
|
||||
|
||||
Corre em `plugins_loaded`, regista o shortcode `[emcp_snippet id="N"]` e carrega os snippets activos.
|
||||
|
||||
**`load()` é manifest-only** (nunca faz scan de directório). Para cada entrada do manifest:
|
||||
1. **Path containment**: `0 !== strpos(normalize(path), normalize(sandbox))` → salta (defende contra manifest envenenado).
|
||||
2. **Tamper guard**: recalcula `hash('sha256', file_get_contents($path))` e compara com o hash registado → salta se não bater certo.
|
||||
3. `include_once` do ficheiro — isto só *define* a função, **não executa** código do utilizador.
|
||||
4. Se `context` for `hook`/`both`, faz `add_action($hook, closure, $priority)` que despoleta `run_on_hook()`.
|
||||
|
||||
**Execução:**
|
||||
- `render_shortcode()` — só corre se `context` for `shortcode`/`both`; captura output via `ob_start()`; se a função devolver string/numérico, é concatenado ao output do buffer.
|
||||
- `run_on_hook()` — corre a função directamente no hook (output vai inline, ex.: `wp_footer`).
|
||||
|
||||
**Isolamento de falhas** — camada dupla:
|
||||
1. `try { ... } catch (\Throwable $e)` em cada execução.
|
||||
2. `register_shutdown_function` como rede de segurança para os fatais que um `try/catch` não apanha (`E_ERROR`, `E_PARSE`, `E_CORE_ERROR`, `E_COMPILE_ERROR`, `E_USER_ERROR`) — se um snippet crasha (mesmo em erro de parse do próprio ficheiro incluído), `mark_error()` desactiva-o automaticamente para a request seguinte recuperar. Um snippet mau não consegue white-screenar o site de forma persistente.
|
||||
|
||||
### 3.3 `EMCP_Tools_PHP_Snippet_Validator` — `includes/class-php-snippet-validator.php` — **SECÇÃO CRÍTICA**
|
||||
|
||||
Duas camadas de validação:
|
||||
|
||||
**Camada 1 — PARSE.** O código é embrulhado exactamente como vai correr: `<?php function __emcp_snippet_validate() { CODE\n}` — e passa por `token_get_all($wrapped, TOKEN_PARSE)`. Um `\ParseError`/`\Throwable` aqui devolve `valid=false` com a mensagem de erro, sem sequer chegar ao scan de segurança.
|
||||
|
||||
**Camada 2 — SECURITY SCAN.** Percorre os tokens significativos (whitespace/comments removidos) e aplica regras por token e por vizinhança (prev/next).
|
||||
|
||||
#### Bloqueio CRÍTICO por nome de função (mapa `severity → reason`, bloqueia `create`/`activate`):
|
||||
|
||||
| Categoria | Funções |
|
||||
|---|---|
|
||||
| Execução de código arbitrário | `eval`, `assert`, `create_function` |
|
||||
| Shell / processo | `exec`, `system`, `shell_exec`, `passthru`, `proc_open`, `popen`, `pcntl_exec`, `expect_popen` |
|
||||
| Invocação dinâmica (bypassa este próprio check) | `call_user_func`, `call_user_func_array`, `forward_static_call`, `forward_static_call_array`, `func_get_args` |
|
||||
| Escritas/apagamentos de ficheiro | `file_put_contents`, `fwrite`, `fputs`, `fputcsv`, `ftruncate`, `unlink`, `rmdir`, `rename`, `copy`, `mkdir`, `chmod`, `chown`, `chgrp`, `symlink`, `link`, `move_uploaded_file` |
|
||||
| Rede | `curl_init`, `curl_exec`, `curl_setopt`, `fsockopen`, `pfsockopen`, `stream_socket_client`, `socket_create`, `socket_connect` |
|
||||
| Decoders de ofuscação (sinal nº1 de malware) | `base64_decode`, `gzinflate`, `gzuncompress`, `gzdecode`, `str_rot13`, `convert_uudecode`, `hex2bin` |
|
||||
| Runtime/ambiente | `dl`, `putenv`, `ini_set`, `ini_alter`, `apache_setenv`, `virtual`, `set_error_handler`, `register_shutdown_function`, `register_tick_function`, `extract` |
|
||||
|
||||
#### Bloqueio CRÍTICO por construto de linguagem (análise de tokens, não lista de nomes):
|
||||
|
||||
- **Backtick shell execution** — `` `...` ``
|
||||
- **`include`/`include_once`/`require`/`require_once`** — bloqueia **sempre**, mesmo estático ("loads and runs another PHP file")
|
||||
- **`T_EVAL`** — construto de linguagem `eval`, além da função
|
||||
- **Chamada de função por variável** — `$var(...)` — "Calls a function named by a variable (bypasses static checks)"
|
||||
- **Instanciação dinâmica** — `new $var` — classe escolhida em runtime
|
||||
- **Reflection/Closure factories** — `new ReflectionFunction/ReflectionMethod/ReflectionClass/ReflectionObject/Closure`
|
||||
- **Tag de fecho PHP embutida** — `?>` — bloqueia porque permitiria "escapar" do wrapper para output HTML cru
|
||||
- **SQL destrutivo dentro de string literal** — regex `/\b(DROP|TRUNCATE|ALTER)\s+(TABLE|DATABASE)\b/i` ou `/\bDELETE\s+FROM\b/i`
|
||||
|
||||
#### Apenas AVISO (`warning`, não bloqueia; fica visível ao revisor humano na UI):
|
||||
|
||||
- Leitura de ficheiros: `fopen`, `file_get_contents`, `readfile`, `fread`, `fgets`, `scandir`, `glob`, `opendir`
|
||||
- `define`, `header`, `setcookie`, `error_reporting`
|
||||
- WordPress sensível: `update_option`, `delete_option`, `add_option`, `wp_mail`, `wp_delete_post`, `wp_delete_user`, `wp_insert_user`, `wp_update_user`, `switch_theme`, `activate_plugin`, `deactivate_plugins`, `do_action`
|
||||
- **Callbacks dinâmicos** — o vector clássico de bypass da lista crítica acima (ex.: `array_map('system', $_GET['cmds'])` evitaria a checagem directa de `system`): `array_map`, `array_filter`, `array_walk`, `array_walk_recursive`, `array_reduce`, `usort`, `uasort`, `uksort`, `ob_start`, `preg_replace_callback`, `preg_replace_callback_array`, `set_exception_handler`, `iterator_apply`
|
||||
- `die`/`exit` (`T_EXIT`)
|
||||
- Variável variável (`$$x`)
|
||||
- Supressão de erro (`@`)
|
||||
- Superglobais: `$_GET`, `$_POST`, `$_REQUEST`, `$_FILES`, `$_COOKIE`, `$_SERVER`, `$_ENV`, `$GLOBALS`
|
||||
- Definição de `function`/`class`/`trait`/`interface` dentro do snippet (risco de redeclaração fatal em re-execuções)
|
||||
|
||||
#### `strip_tags()`
|
||||
Remove um único `<?php` (ou `<?=`/`<?`) inicial via regex, para aceitar código com ou sem tags de abertura — usado tanto na validação como na compilação final.
|
||||
|
||||
#### Postura de segurança declarada (citação directa do topo do ficheiro):
|
||||
> *"IMPORTANT — this is a GUARDRAIL, not a guarantee. PHP is expressive enough to hide intent (variable functions, decoded strings, reflection), so static analysis cannot prove arbitrary code is safe. The real safety boundary is the capability gate (manage_options + unfiltered_html) plus the human approval step: an AI can create a DRAFT and run the validator, but only an admin can activate a snippet so it actually executes."*
|
||||
|
||||
Esta mensagem é reforçada literalmente na UI de administração (`admin/views/sandbox/snippets.php`): *"The validator blocks obviously dangerous code (...), but static analysis is a guardrail, not a guarantee, only activate code you have read and trust. Activation is the approval step; AI can only create inactive drafts."*
|
||||
|
||||
### 3.4 `EMCP_Tools_Sandbox_Paths` — `includes/sandbox/class-sandbox-paths.php`
|
||||
|
||||
Centraliza **todos** os caminhos de sandbox (usado por snippets, widgets, blocks Pro, theme-php).
|
||||
|
||||
- **Base dir:** `wp-content/emcp-sandbox` (nome filtrável via `emcp_tools_sandbox_folder`, caminho absoluto filtrável via `emcp_tools_sandbox_dir`, URL via `emcp_tools_sandbox_url`).
|
||||
- **Localização legada:** `wp-content/uploads/emcp-widgets` — antes de a v3.7 introduzir a pasta única sob `wp-content/`, os artefactos viviam dispersos sob uploads.
|
||||
- **`maybe_migrate()`** — migração automática one-time, corre no bootstrap (`plugins_loaded`), antes dos loaders (`init`). Tenta `rename()`; se falhar (device diferente), faz `copy_tree()` + `rmdir_tree()` como fallback. Guarda o flag em `option('emcp_tools_sandbox_location')` para nunca repetir. **Insight de design:** os manifests guardam caminhos *relativos* + hashes de conteúdo — por isso a migração nunca precisa de reconstruir um único manifest, todos os hashes continuam válidos contra a nova base.
|
||||
- **`harden()`** — escreve um `index.php` de silêncio + um `.htaccess` que bloqueia execução directa de `.php` (`<FilesMatch "\.php$"> Require all denied`) mas continua a permitir servir `.css`/`.js` estáticos.
|
||||
- **`guard_subdir()`** — garante `index.php` de silêncio em cada subpasta nova.
|
||||
- **`relative_base()`** — caminho relativo a `ABSPATH`, usado pelo scanner de malware (ver `skill://emcp-tools`, já auditada) para **excluir** o próprio PHP sandboxado do plugin da verificação de malware.
|
||||
|
||||
### 3.5 `EMCP_Tools_Sandbox_Bundle` — `includes/sandbox/class-sandbox-bundle.php`
|
||||
|
||||
Envelope de portabilidade partilhado por todos os `kind`s (`block`, `widget`, `snippet`).
|
||||
|
||||
- `SCHEMA_VERSION = 1`, `KINDS = ['block', 'widget', 'snippet']`.
|
||||
- **`build()`** — monta `{schema_version, kind, uuid, meta, spec, assets, version, updated_at, checksum}`.
|
||||
- **`checksum()`** — `ksort($assets)` seguido de `sha256(wp_json_encode($assets))` — determinístico, prefixado `sha256:`.
|
||||
- **`validate()`** — valida `schema_version` (1..`SCHEMA_VERSION`), `kind` conhecido, presença de todas as chaves obrigatórias, `assets` é array, e **recalcula o checksum e compara** — devolve `WP_Error('bundle_checksum', ...)` ("tampered or corrupt") se não bater certo.
|
||||
|
||||
### 3.6 `EMCP_Tools_Sandbox_Store` (abstract) — `includes/sandbox/class-sandbox-store.php`
|
||||
|
||||
Classe-base comum para stores tipo-artefacto, implementa `EMCP_Tools_Sandbox_Artifact` parcialmente.
|
||||
|
||||
- Meta keys partilhadas: `_emcp_uuid`, `_emcp_origin`, `_emcp_remote_id`, `_emcp_sync_state`, `_emcp_version`, `_emcp_updated_at`.
|
||||
- `ensure_uuid()`/`uuid()` — gera UUID4 uma vez, persiste.
|
||||
- `bump_version()` — incrementa versão, marca `sync_state='dirty'`, regista `updated_at`.
|
||||
- `sync_meta()` — pacote de metadados de sincronização cloud.
|
||||
- `artifact_dir()`/`artifact_url()` — caminho e URL por artefacto (útil para enfileirar assets próprios de um artefacto, ex. script de editor de um bloco, cujo URL o resolver `file:` de `block.json` do WordPress não consegue calcular para uma sandbox fora de um plugin/tema).
|
||||
- Helpers I/O partilhados: `write_file`/`read_file`/`delete_file`/`rmdir_recursive` (com invalidação de opcache em escritas `.php`).
|
||||
|
||||
**Nota de arquitectura:** apesar de existir, esta classe abstract **não é usada pelo `EMCP_Tools_Widget_Store`** — que reimplementa manualmente os mesmos padrões (write_file/read_file/rmdir_recursive/sync-like meta). É consumida pelo Block Store (Pro, ausente desta build). A explicação plausível: o Widget Store é `@since 1.9.0` (anterior), esta classe `sandbox/` é claramente da geração `@since 3.7.0` dos adapters cloud — dívida técnica de evolução do produto, não um erro.
|
||||
|
||||
### 3.7 Adapters — `class-snippet-bundle-adapter.php` e `class-widget-bundle-adapter.php`
|
||||
|
||||
Ambos implementam `EMCP_Tools_Sandbox_Artifact` **sem tocar** no store subjacente — thin adapters puros.
|
||||
|
||||
| | `EMCP_Tools_Snippet_Bundle_Adapter` | `EMCP_Tools_Widget_Bundle_Adapter` |
|
||||
|---|---|---|
|
||||
| `assets()` | `{code.php: código raw NÃO compilado}` — deliberadamente **não** o executável envolvido em função (esse é maquinaria local do store, re-derivada em cada import) | `{widget.php, style.css?, script.js?}` — os ficheiros **gerados** (compilados) |
|
||||
| `to_bundle()` | Monta via `EMCP_Tools_Sandbox_Bundle::build('snippet', ...)` | Monta via `EMCP_Tools_Sandbox_Bundle::build('widget', ...)`, usando `EMCP_Tools_Widget_Store::get_spec()` como `spec` |
|
||||
| `apply_bundle()` | Chama `EMCP_Tools_PHP_Snippet_Store::create_draft()` — sempre novo draft, **nunca activa** | Chama `EMCP_Tools_Widget_Store::create($spec, false)` — `$active=false` explícito, mesmo princípio |
|
||||
|
||||
Ambos gravam `_emcp_uuid = bundle.uuid` e `_emcp_origin = 'imported'` no post recém-criado. **O portão de aprovação humana é preservado mesmo no fluxo de import** — importar um bundle nunca substitui a necessidade de um administrador activar o resultado.
|
||||
|
||||
### 3.8 `interface EMCP_Tools_Sandbox_Artifact` — `includes/sandbox/interface-sandbox-artifact.php`
|
||||
|
||||
Contrato mínimo, consumido por `EMCP_Tools_Sandbox_Cloud_Abilities::resolve_artifact()`:
|
||||
```php
|
||||
interface EMCP_Tools_Sandbox_Artifact {
|
||||
public function kind(): string;
|
||||
public function uuid( int $id ): string;
|
||||
public function to_bundle( int $id ); // array|WP_Error
|
||||
public function apply_bundle( array $bundle ); // int|WP_Error (novo id local)
|
||||
public function checksum( int $id ): string;
|
||||
public function sync_meta( int $id ): array;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.9 `EMCP_Tools_Widget_Store` — `includes/class-widget-store.php` (839 linhas na tarefa)
|
||||
|
||||
**CONFIRMAÇÃO DIRECTA:** este ficheiro **existe** na build Free (lido na íntegra via SSH). O que **não** existe é `includes/abilities/class-widget-builder-abilities.php` — ausente da listagem de `includes/abilities/` neste servidor. O registrar só instancia condicionalmente:
|
||||
```php
|
||||
// Widget Builder (Pro; self-guards on license).
|
||||
if ( class_exists( 'EMCP_Tools_Widget_Builder_Abilities' ) ) {
|
||||
$widget_builder = new EMCP_Tools_Widget_Builder_Abilities();
|
||||
$widget_builder->register();
|
||||
...
|
||||
}
|
||||
```
|
||||
Como o ficheiro dessa classe não existe, `class_exists()` é sempre `false` nesta build → **`create-custom-widget`, `create-custom-block` e afins NÃO estão registadas** como MCP abilities. "Self-guards on license" refere-se ao comportamento *se* o overlay Pro privado estivesse presente: a própria classe verificaria a licença dentro de si. Aqui nem chega a ser definida.
|
||||
|
||||
Também confirmado ausente: `includes/class-widget-generator.php` (o compilador spec→PHP) e `includes/class-block-store.php` — nenhum dos dois consta da listagem de `includes/` desta build. Isto confirma explicitamente a nota da própria `class-sandbox-cloud-abilities.php`: *"the 'block' kind is Pro-only via the resolver: `EMCP_Tools_Block_Store` is absent on free sites"*.
|
||||
|
||||
**Duas camadas de gate independentes para a funcionalidade real** (mesmo hipotetizando que a classe de abilities existisse):
|
||||
|
||||
1. **Gate de licença Freemius** (soft — a classe corre, devolve `false`): `user_has_access()` → `emcp_tools_fs()->can_use_premium_code() && current_user_can('manage_options')`.
|
||||
2. **Gate de código ausente** (hard): `write_widget()` verifica `class_exists('EMCP_Tools_Widget_Generator')` — o compilador spec→PHP, ficheiro Pro-only ausente desta árvore. Se ausente, devolve `WP_Error('emcp_pro_required', 'Widget Builder requires EMCP Pro.')` mesmo que `user_has_access()` fosse `true`.
|
||||
|
||||
**O que a classe faz** (arquitectura, documentada mesmo sem poder correr nesta build):
|
||||
|
||||
- CPT privado `emcp_widget`. Meta: `_emcp_spec` (JSON regenerável, **fonte-de-verdade**), `_emcp_widget_name`, `_emcp_class_name`, `_emcp_php_hash`, `_emcp_css_hash`, `_emcp_js_hash`, `_emcp_last_error`.
|
||||
- Post status = activação: `publish` = activo (carregado no Elementor), `draft` = inactivo.
|
||||
- Storage: `wp-content/emcp-sandbox/widgets/{id}/widget.php` (+ `style.css`/`script.js` opcionais).
|
||||
- **Nunca escreve no tema, no core ou noutros plugins** — sandbox isolada, tal como os snippets (declarado no cabeçalho do ficheiro).
|
||||
- **`create(spec, active=true)`**: insere o post primeiro (para o ID poder semear nomes únicos de classe `EMCP_Widget_{id}`/widget `emcp_custom_{id}`), depois `write_widget()`, e se `$active` chama `safeguard_active()` **antes** de aparecer no manifest.
|
||||
- **`write_widget()`**: chama `EMCP_Tools_Widget_Generator::generate($spec, $class_name, $widget_name, $handles)` — compila a spec estruturada em PHP. **O agente de IA nunca escreve PHP cru, só a spec JSON** — confirmado pelo texto da própria UI admin: *"these widgets are PHP compiled by this plugin from an AI-supplied spec (the AI never writes raw PHP). Output is escaped by control type."*
|
||||
- **Defesa de injecção nos assets estáticos**: CSS/JS aceitam ficheiros completos opcionais, mas com `preg_replace('/<\?(?:php|=)?/i', '', $out)` — garante que um `.css`/`.js` gerado nunca pode ser executado como PHP mesmo com `short_open_tag` activo num servidor mal configurado.
|
||||
- **`runtime_validate()` — safeguard notável**: instancia a classe gerada e chama `$instance->get_stack()` (força `init_controls()` → `register_controls()`, exactamente o que o editor Elementor faz), **antes** de deixar o widget ir para produção. Se rebentar, é apanhado aqui em vez de dar white-screen no painel do editor Elementor. Chamado tanto em `create()` como em `set_status('active')`.
|
||||
- **`safeguard_active()`**: se a validação runtime falhar, **degrada automaticamente para draft** e regista o erro — nunca deixa algo quebrado ficar "activo".
|
||||
- **`mark_error()`**: chamado pelo *loader* em runtime (paralelo ao snippet loader) quando um widget crasha após já ter sido carregado.
|
||||
- Mesmo padrão manifest-only (`rebuild_manifest()`/`read_manifest()`) dos snippets.
|
||||
- **`uninstall_cleanup()`**: apaga todos os posts e faz `rmdir_recursive(self::sandbox_dir())` — **a árvore sandbox inteira**, não só `widgets/`. Note-se que isto é diferente do `PHP_Snippet_Store::uninstall_cleanup()`, que só apaga os seus próprios ficheiros individuais — risco arquitectural a evitar numa réplica se vários subsistemas partilharem a mesma raiz de sandbox (aqui não causa dano porque o desinstalar do plugin apaga tudo de uma vez, mas é frágil).
|
||||
|
||||
### 3.10 `EMCP_Tools_Widget_Loader` — `includes/class-widget-loader.php` (bónus — runtime companion do Widget Store)
|
||||
|
||||
Não foi pedido explicitamente na lista de ficheiros, mas foi lido para fechar a compreensão do ciclo de vida completo do widget (paralelo directo ao `PHP_Snippet_Loader`).
|
||||
|
||||
- `has_access()`: mesmo gate Freemius do Store — *"The whole loader is Pro-gated: on a free/unlicensed site nothing is loaded"* (comentário do próprio autor).
|
||||
- Regista categoria Elementor **"Custom (EMCP)"** (slug `emcp-custom`) só se `has_access()`.
|
||||
- `register_widgets()`: manifest-only, tamper guard sha256, path-containment guard — padrão idêntico ao snippet loader.
|
||||
- `register_assets()`: `wp_register_style`/`wp_register_script` por widget com handle `emcp-widget-{id}-style`/`-script`, **versionados pelo hash do ficheiro** (cache-busting automático em cada regeneração).
|
||||
- Mesma rede de segurança de shutdown handler + isolamento de fatais que o snippet loader.
|
||||
|
||||
### 3.11 `EMCP_Tools_Mcpb_Builder` — `includes/admin/class-mcpb-builder.php` — **NÃO é "Widget/Block Builder"**
|
||||
|
||||
**Desambiguação explícita, confirmada por leitura directa do ficheiro:** apesar de o nome "MCP Builder" sugerir parentesco com o Widget/Block Builder, esta é uma feature **completamente distinta e não relacionada** com o sandbox de código custom. Constrói um bundle `.mcpb` (formato Claude Desktop Extension) que instala um **servidor MCP standalone** que faz proxy para o REST API do WordPress — é o mecanismo para ligar o Claude Desktop directamente a este site sem passar por um MCP host remoto.
|
||||
|
||||
- **`build_manifest()`**: gera `manifest.json` versão MCPB `0.3`. O `name` é **único por site**, derivado do host (`host_slug()`), porque *"Claude Desktop identifies extensions by the manifest `name` (not the filename or `display_name`), so the name must be unique per site or a second install replaces the first"* — comentário que referencia um bug real (**#86**) já reportado.
|
||||
- `mcp_config.args` usa `${__dirname}/server/index.js` (variável de substituição MCPB) em vez de caminho relativo — comentário do autor explica: *"Claude Desktop does not cd into the extracted bundle dir before running `node`, so a relative path resolves against the wrong CWD and Node throws 'Cannot find module' → instant 'Server disconnected'"*.
|
||||
- **`build_zip()`**: lê `bin/mcp-proxy.mjs` (fonte ESM, testável com `node --test`), converte para CJS self-contained via regex simples (troca `import ... from 'node:X'` por `require('X')`, remove `export`), e **embute as credenciais** (`WP_URL`, `WP_USERNAME`, `WP_APP_PASSWORD`) como overrides de `process.env` no topo do ficheiro — para funcionar mesmo que o host Claude Desktop não injecte `mcp_config.env`.
|
||||
- **`validate_server_js()`**: sanity check pós-build — confirma presença dos 4 `require()` esperados e ausência de qualquer `import`/`export` ESM residual; falha cedo em vez de distribuir um bundle partido.
|
||||
- Disparado pelo admin-post `emcp_tools_download_mcpb` (`NONCE_DOWNLOAD_MCPB`, visto em `includes/admin/class-admin.php`), a partir do separador **"Connection"** das definições do plugin — **nada a ver com a página Sandbox**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Blueprint para réplica
|
||||
|
||||
### Copiar quase 1:1
|
||||
|
||||
1. **O validador em 3 camadas** (parse → scan de tokens → classificação critical/warning) — é o coração da segurança deste subsistema e está bem pensado: cobre tanto nomes de função óbvios como vectores de bypass (chamada dinâmica, callbacks). A lista de funções críticas está bem pesquisada; copiar quase literalmente.
|
||||
2. **O padrão "manifest-only loading + tamper guard sha256 + path containment check"** — usado tanto no snippet loader como no widget loader. É elegante e evita I/O desnecessário em cada request (nunca faz scan de directório).
|
||||
3. **O padrão "shutdown handler + auto-deactivate on fatal"** — garante que um snippet/widget mau nunca consegue white-screenar o site de forma persistente (recupera na request seguinte). Detalhe fácil de esquecer numa reescrita ingénua.
|
||||
4. **O gate de duas camadas para escrita de PHP** (`create` só pode produzir draft; `activate` é acção humana separada, nunca exposta via MCP) — é a decisão de design mais importante deste subsistema todo e deve ser preservada tal e qual: *"There is intentionally no 'activate' tool"* no MCP.
|
||||
5. **O envelope de bundle** (`EMCP_Tools_Sandbox_Bundle`) com checksum determinístico (`ksort` + `sha256`) — simples e eficaz para portabilidade/import-export entre sites.
|
||||
6. **A defesa de injecção de tag PHP** em ficheiros CSS/JS gerados (`preg_replace('/<\?(?:php|=)?/i', '', $out)`) — detalhe fácil de esquecer que evita um vector de RCE se `short_open_tag` estiver activo no servidor de destino.
|
||||
7. **`runtime_validate()` do Widget Store** — instanciar e forçar `get_stack()` antes de activar, para nunca deixar código gerado partido chegar ao editor Elementor.
|
||||
|
||||
### Simplificar
|
||||
|
||||
- A dualidade `EMCP_Tools_Sandbox_Store` (abstract, `@since 3.7.0`) vs a implementação manual duplicada em `EMCP_Tools_Widget_Store` (`@since 1.9.0`) é dívida técnica visível — ambas fazem o mesmo (`write_file`/`read_file`/`rmdir_recursive`/sync-meta) com código quase idêntico. Numa reescrita, fazer o Widget Store herdar de `Sandbox_Store` desde o início.
|
||||
- A migração legada `uploads/emcp-widgets` → `wp-content/emcp-sandbox` (`EMCP_Tools_Sandbox_Paths::maybe_migrate`) só é necessária porque o produto original mudou de local a meio da vida. Numa réplica de raiz, ir directo para `wp-content/{slug}-sandbox` sem essa bagagem.
|
||||
|
||||
### Deixar de fora ou adiar
|
||||
|
||||
- **O MCPB Builder** (Claude Desktop `.mcpb`) é uma feature auxiliar completamente ortogonal ao sandbox — só implementar se o objectivo for também suportar o cliente Claude Desktop nativo além de MCP hosts remotos. Não é "custom code sandbox", é "distribuição de credenciais de ligação".
|
||||
- **O sistema cloud/marketplace** (Save to Cloud/Publish/View on Marketplace, visto nas views admin como `EMCP_Tools_Admin::render_sandbox_cloud_actions()`/`render_cloud_library()`) claramente pertence a outro documento (provavelmente o de integrações cloud/OAuth) — não aprofundado aqui, apenas mencionado como consumidor do contrato `EMCP_Tools_Sandbox_Artifact`.
|
||||
|
||||
### Gotchas não óbvios (citações directas)
|
||||
|
||||
- *"Claude Desktop identifies extensions by the manifest `name` (not the filename or `display_name`), so the name must be unique per site or a second install replaces the first"* — referenciando o bug **#86** relatado.
|
||||
- *"Claude Desktop does not cd into the extracted bundle dir before running `node`, so a relative path resolves against the wrong CWD"* — motivo de usar `${__dirname}`.
|
||||
- *"PHP is expressive enough to hide intent (variable functions, decoded strings, reflection), so static analysis cannot prove arbitrary code is safe"* — o autor é honesto sobre os limites do validador, não vende segurança que não tem.
|
||||
- `EMCP_Tools_Widget_Store::uninstall_cleanup()` apaga `sandbox_dir()` **inteiro** (não só `widgets/`) — cuidado ao portar esta lógica se outros artefactos (snippets, blocks) partilharem a mesma raiz de sandbox; podia apagar dados de outros subsistemas por engano num desinstalar parcial (aqui não acontece porque o plugin desinstala tudo de uma vez, mas é um risco arquitectural a ter em conta numa réplica com desinstalação modular).
|
||||
- A lista de funções `warning` inclui explicitamente callbacks dinâmicos (`array_map`, `usort`, `preg_replace_callback`, etc.) precisamente porque são o vector clássico para contornar a lista `critical` de chamada directa (ex.: `array_map('system', $input)`) — um detalhe de segurança sofisticado que vale a pena preservar em qualquer réplica do validador.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fonte
|
||||
|
||||
Todos os ficheiros lidos via `ssh://server/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`:
|
||||
|
||||
- `includes/abilities/class-php-snippet-abilities.php`
|
||||
- `includes/class-php-snippet-store.php`
|
||||
- `includes/class-php-snippet-loader.php`
|
||||
- `includes/class-php-snippet-validator.php`
|
||||
- `includes/abilities/class-sandbox-cloud-abilities.php`
|
||||
- `includes/sandbox/class-sandbox-paths.php`
|
||||
- `includes/sandbox/class-snippet-bundle-adapter.php`
|
||||
- `includes/sandbox/class-sandbox-bundle.php`
|
||||
- `includes/sandbox/class-sandbox-store.php`
|
||||
- `includes/sandbox/class-widget-bundle-adapter.php`
|
||||
- `includes/sandbox/interface-sandbox-artifact.php`
|
||||
- `includes/class-widget-store.php`
|
||||
- `includes/admin/class-mcpb-builder.php`
|
||||
- `includes/class-widget-loader.php` (bónus, consultado para confirmar o runtime companion do Widget Store)
|
||||
- `includes/admin/views/sandbox/widgets.php` (confirmação da UI Pro-gated do Widget Builder)
|
||||
- `includes/admin/views/sandbox/blocks.php` (confirmação de que `EMCP_Tools_Block_Store` também é Pro-only/ausente)
|
||||
- `includes/admin/views/sandbox/snippets.php` (confirmação da UI de aprovação humana dos snippets)
|
||||
- `includes/abilities/class-ability-registrar.php` (grep pontual, linhas ~416-434 e ~549-554, gating exacto dos grupos)
|
||||
- `includes/admin/class-admin.php` (grep pontual: admin-post `handle_download_mcpb` + ajax handlers de toggle/delete de widget/block)
|
||||
- `includes/abilities/class-custom-code-abilities.php` (verificação rápida — confirmar que é feature distinta: Elementor Custom CSS/JS/Code Snippets, não faz parte deste subsistema)
|
||||
- Listagens de directório (via `read` em modo directório): `includes/abilities/`, `includes/`, `includes/admin/`, `includes/admin/views/sandbox/` — usadas para confirmar a **ausência** de `class-widget-builder-abilities.php`, `class-widget-generator.php` e `class-block-store.php` nesta build Free.
|
||||
@@ -0,0 +1,629 @@
|
||||
# 07 — System Ops: Filesystem, Base de Dados, WP-CLI, Security Scanner, Performance Analyzer
|
||||
|
||||
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
|
||||
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
|
||||
19-08-2026. Cruzado com `docs/00-ARQUITECTURA.md` (arquitectura geral, cadeia de arranque,
|
||||
`emcp_tools_register_ability()`) e `skill://emcp-tools` (postura de segurança ao vivo nos 3
|
||||
sites do ecossistema, mecanismo do deny-list incremental).
|
||||
|
||||
**Estes são os grupos de MAIOR RISCO do plugin.** Todas as 6 tools de filesystem que mutam
|
||||
estado, todas as 6 de base de dados que mutam estado, e as 4 de WP-CLI (incluindo as duas de
|
||||
só leitura, `get-wp-cli-job`/`list-wp-cli-jobs`) fazem parte dos 131 slugs desligados por
|
||||
omissão em `emanuelalmeida.pt`/`starter.descomplicar.pt` (ver `skill://emcp-tools` §2.1/§2.2).
|
||||
Security Scanner e Performance Analyzer são as únicas duas ferramentas deste documento que ficam
|
||||
**activas por omissão** — são estritamente de leitura, nunca escrevem nada.
|
||||
|
||||
Todos os grupos deste documento registam-se **sempre** (não dependem de Elementor activo, nem de
|
||||
nenhum módulo opcional) — são chamados directamente em `EMCP_Tools_Ability_Registrar::register_groups()`,
|
||||
fora de qualquer bloco condicional `if ( class_exists(...) )` ou `if ( $elementor_active )`. A
|
||||
única coisa que os torna "inúteis" num site com config por omissão é estarem no deny-list
|
||||
aplicado por `EMCP_Tools_Plugin::filter_disabled_tools()` (ver doc 00 §3).
|
||||
|
||||
---
|
||||
|
||||
## 1. Filesystem
|
||||
|
||||
**Classe de abilities:** `EMCP_Tools_Filesystem_Abilities`
|
||||
(`includes/abilities/class-filesystem-abilities.php`)
|
||||
**Condição de registo:** sempre activo (chamado sem guarda em `register_groups()`).
|
||||
**`permission_callback` (todas as 6 tools):** `current_user_can( 'manage_options' )` — mesmo as
|
||||
de leitura, porque `read-file`/`search-files` podem expor segredos de config de outros
|
||||
ficheiros do site.
|
||||
|
||||
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|
||||
|---|---|---|---|
|
||||
| `read-file` | `path` (string, **required**), `offset` (int, 1-based), `limit` (int) | Lê um ficheiro dentro de `ABSPATH`. Limite de 5 MB (`MAX_READ_BYTES`); recusa binários (devolve `{binary:true}` em vez do conteúdo); recusa `wp-config.php` (`is_read_protected`); suporta slice por linhas via `offset`/`limit`. | readonly / não destrutivo |
|
||||
| `list-directory` | `path` (opt, default raiz), `recursive` (bool, profundidade máx. 5, cap 2000 entradas) | Lista entradas (nome/path/tipo/size/mtime) de um directório dentro de `ABSPATH`. | readonly |
|
||||
| `search-files` | `query` (string, **required**), `path` (opt), `extensions` (array de string), `max_results` (int, default 200, tecto 500) | Grep de substring (case-sensitive) recursivo por uma árvore; ignora ficheiros >5 MB e `wp-config.php`; devolve `{file, line, text}` capado a 300 chars por match. | readonly |
|
||||
| `write-file` | `path`, `content` (ambos **required**) | Cria ou sobrescreve um ficheiro. Faz backup do existente primeiro; recusa `wp-config.php`/`.htaccess`; limite 5 MB (`MAX_WRITE_BYTES`); requer `writes_allowed()` (capability `edit_files` + `!DISALLOW_FILE_EDIT`); invalida OPcache se `.php`; grava no change ledger unificado. | **não readonly, destrutivo** — **desligado por omissão** |
|
||||
| `edit-file` | `path`, `old_string`, `new_string` (**required**), `replace_all` (bool) | Substituição exacta de string (`old_string` deve corresponder exactamente uma vez, a menos que `replace_all`). Backup, mesmo gate de escrita, mesma protecção de ficheiros, mesmo registo no ledger. | **destrutivo** — **desligado por omissão** |
|
||||
| `delete-file` | `path` (**required**), `confirm` (bool) | Apaga um ficheiro. Exige `confirm:true`; backup antes de apagar; mesmo gate de escrita e protecção. | **destrutivo** — **desligado por omissão** |
|
||||
|
||||
### Guard: `EMCP_Tools_Filesystem_Guard`
|
||||
|
||||
Ficheiro: `includes/class-filesystem-guard.php`. Comentário do próprio ficheiro: *"This is the
|
||||
security boundary for the filesystem tools. resolve_path() is the one chokepoint that makes
|
||||
'inside the WordPress install only' true."*
|
||||
|
||||
Constantes: `MAX_READ_BYTES = 5242880` (5 MB), `MAX_WRITE_BYTES = 5242880` (5 MB),
|
||||
`BACKUP_DIR = 'emcp-fs-backups'`.
|
||||
|
||||
- **`resolve_path( string $path, ?string $root = null )`** — o chokepoint único. `$root`
|
||||
por omissão `ABSPATH` (parâmetro só existe para testes). Rejeita path vazio ou com byte NUL.
|
||||
Detecta se é absoluto (começa por `/`, `\`, ou `C:\`-like via regex). Constrói o candidato
|
||||
(absoluto tal-e-qual, ou `rtrim($root) . '/' . ltrim($path)`). Faz `realpath()`; se o próprio
|
||||
alvo não existir ainda (caso de escrita nova), resolve o **directório pai** com `realpath()`
|
||||
e reconstrói `parent/basename`. Compara o prefixo do caminho resolvido contra
|
||||
`realpath($root)`: tem de ser **exactamente igual** ou começar por
|
||||
`root . DIRECTORY_SEPARATOR` — nunca um simples `strpos`, para evitar que
|
||||
`/var/www/site-evil` passe por prefixo de `/var/www/site`. Devolve `WP_Error('outside_root')`
|
||||
se escapar.
|
||||
- **`is_protected( string $abs )`** — lista de basenames **escrita/eliminação**-protegidos:
|
||||
`wp-config.php`, `.htaccess` (case-insensitive), filtrável via `emcp_tools_fs_protected_paths`.
|
||||
- **`is_read_protected( string $abs )`** — lista de basenames **leitura**-protegidos: **só**
|
||||
`wp-config.php` — `.htaccess` fica de fora porque não é um segredo, comentário explícito no
|
||||
código: *"wp-config.php carries the DB credentials and auth salts; .htaccess is not a secret
|
||||
so it stays readable."* Filtrável via `emcp_tools_fs_read_protected_paths` — um admin pode
|
||||
adicionar `.env`, ficheiros de chave, etc. **Nota importante:** as duas listas são
|
||||
deliberadamente diferentes (escrita ⊃ leitura) — não é o mesmo array reutilizado.
|
||||
- **`backup_name( rel, timestamp )`** — pure: nome de ficheiro sanitizado
|
||||
`<timestamp>-<path-com-/-substituído-por-\->`, caracteres fora de `A-Za-z0-9._-` viram `-`.
|
||||
- **`is_utf8( content )`** — pure: byte NUL ou falha em `preg_match('//u', $content)` → binário.
|
||||
- **`check_writes( can_edit_files, disallow_file_edit )`** — pure: combina a capability
|
||||
`edit_files` com a constante `DISALLOW_FILE_EDIT`.
|
||||
- **`writes_allowed()`** — wrapper live: `current_user_can('edit_files') && !(defined(DISALLOW_FILE_EDIT) && DISALLOW_FILE_EDIT)`.
|
||||
- **`to_relative( abs )`** — inverso de `resolve_path` para display/log (relativo a `ABSPATH`,
|
||||
slashes normalizados).
|
||||
- **`backup( abs )`** — copia o ficheiro-alvo para
|
||||
`wp-content/uploads/emcp-fs-backups/<timestamp>-<path-flat>` **antes** de qualquer
|
||||
write/edit/delete. Cria `.htaccess` (`Require all denied`) + `index.html` vazio no directório
|
||||
de backups na primeira utilização (bloqueia acesso web directo aos backups). Devolve `''`
|
||||
quando o ficheiro-fonte ainda não existe (é uma criação, não um overwrite) — nesse caso o
|
||||
rollback ficheiro-a-ficheiro é do tipo `'file-create'` em vez de `'file-backup'`.
|
||||
- **`log()`** — `@deprecated 3.10.0`, no-op. Auditoria foi migrada para o change ledger unificado
|
||||
(`EMCP_Tools_Change_Log`/`EMCP_Tools_Change_Recorder::record_file()`, documentado no doc 05) —
|
||||
`class-filesystem-abilities.php::record_fs_change()` monta a entrada do ledger com `rollback`
|
||||
do tipo `file-backup`/`file-create` e chama o recorder directamente.
|
||||
|
||||
Efeito colateral extra em cada escrita/edição/eliminação de `.php`: `invalidate_php_opcache()`
|
||||
chama `opcache_invalidate($abs, true)` — sem isto, o pedido seguinte podia executar bytecode
|
||||
cached obsoleto em vez do ficheiro recém-alterado.
|
||||
|
||||
**Storage:** backups em `wp-content/uploads/emcp-fs-backups/` (protegido de acesso web via
|
||||
`.htaccess`). Registo de mudanças no change ledger unificado (ver doc 05) — nenhum log próprio
|
||||
separado.
|
||||
|
||||
---
|
||||
|
||||
## 2. Base de dados directa
|
||||
|
||||
**Classe de abilities:** `EMCP_Tools_Database_Abilities`
|
||||
(`includes/abilities/class-database-abilities.php`)
|
||||
**Condição de registo:** sempre activo.
|
||||
**`permission_callback` (todas as 6 tools):** `current_user_can( 'manage_options' )`.
|
||||
|
||||
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|
||||
|---|---|---|---|
|
||||
| `list-tables` | (sem input) | Lista tabelas via `information_schema.TABLES` — nome, `table_rows` estimado, tamanho em bytes (`data_length + index_length`). | readonly |
|
||||
| `describe-table` | `table` (string, **required**) | Valida o nome contra `EMCP_Tools_Database_Guard::valid_table()` e corre `DESCRIBE`; devolve colunas/tipos/keys. | readonly |
|
||||
| `query` | `sql` (string, **required**), `limit` (int, default/tecto `MAX_ROWS=1000`) | Corre SQL de leitura validado por `is_read_only_sql()`. Recusa também leitura das tabelas `users`/`usermeta` mesmo em modo `SELECT` (`query_touches_protected`), apontando para `list-users`/`get-user` em vez disso. | readonly |
|
||||
| `insert-row` | `table`, `data` (objecto) — ambos **required** | `$wpdb->insert()` parametrizado. Recusa tabelas protegidas. Regista no ledger (`rollback.type = db-before-image`, `op = insert`). | **destrutivo** — **desligado por omissão** |
|
||||
| `update-rows` | `table`, `data`, `where` (todos objecto) — **required** | Exige `where` não-vazio (nunca um UPDATE sem condição). Captura `before_image()` (snapshot das linhas afectadas, cap 500) antes de `$wpdb->update()`. Recusa tabelas protegidas. | **destrutivo** — **desligado por omissão** |
|
||||
| `delete-rows` | `table`, `where` (**required**), `confirm` (bool) | Exige `confirm:true` + `where` não-vazio. `before_image()` antes de `$wpdb->delete()`. Recusa tabelas protegidas. | **destrutivo** — **desligado por omissão** |
|
||||
|
||||
### Guard: `EMCP_Tools_Database_Guard`
|
||||
|
||||
Ficheiro: `includes/class-database-guard.php`. Comentário: *"is_read_only_sql() is the safety
|
||||
boundary for the flexible read path."* Constantes: `MAX_ROWS = 1000`, `BEFORE_IMAGE_CAP = 500`.
|
||||
|
||||
- **`normalize_sql( string $sql )`** — pure, scanner char-a-char (não regex — evita
|
||||
problemas de backtracking/ReDoS em SQL longo). Substitui todo o comentário (`--`, `#`,
|
||||
`/* */`) por um espaço, todo o literal de string (`'...'`/`"..."`, com escapes de backslash e
|
||||
duplicação de quote reconhecidos) por `''`, e todo o identificador entre backticks por
|
||||
`` `` ``. Não trata `/*! ... */` (comentários executáveis do MySQL) — esses são rejeitados
|
||||
**antes** mesmo de chamar `normalize_sql`.
|
||||
- **`is_read_only_sql( string $sql )`** — a gate real, em cadeia:
|
||||
1. Se `$sql` contém `/*!` → rejeita de imediato. Comentário: *"MySQL executes the body of
|
||||
/*! ... */ executable comments, so we cannot safely strip-and-trust."*
|
||||
2. `normalize_sql()` + `trim()`.
|
||||
3. Multi-statement: qualquer `;` que não seja o único carácter final (depois de `rtrim`)
|
||||
→ rejeita.
|
||||
4. Vectores de acesso a ficheiros — regex
|
||||
`/\b(into\s+outfile|into\s+dumpfile|load_file\s*\(|load\s+data\b)/i`. Nota deliberada no
|
||||
código: **sem `\b` a fechar** — porque `load_file(` termina em `(`, e `(` seguido de outro
|
||||
não-word-char não tem word boundary; um `\b` final deixaria passar `LOAD_FILE` por engano.
|
||||
5. Primeira palavra tem de ser uma de `SELECT/SHOW/DESCRIBE/DESC/EXPLAIN/WITH`.
|
||||
6. Denylist da frase inteira: `INSERT|UPDATE|DELETE|REPLACE|MERGE|DROP|TRUNCATE|ALTER|CREATE|RENAME|GRANT|REVOKE|HANDLER|CALL|LOCK|UNLOCK|PREPARE|EXECUTE|INTO` — como comentários/literais já
|
||||
foram removidos por `normalize_sql`, isto só apanha keywords reais (não texto dentro de
|
||||
uma string).
|
||||
- **`valid_table( string $table )`** — nomes de tabela **não podem ser parametrizados** em SQL
|
||||
(`?`/`%s` só serve para valores), por isso resolve contra `SHOW TABLES` ao vivo e devolve o
|
||||
nome real exacto (preserva case) ou `WP_Error('unknown_table')`.
|
||||
- **`table_is_protected`/`is_protected( $table )`** — tabelas protegidas por omissão:
|
||||
`$wpdb->users`, `$wpdb->usermeta`, filtrável via `emcp_tools_db_protected_tables`.
|
||||
- **`query_touches_tables`/`query_touches_protected( $sql )`** — o análogo do lado da leitura:
|
||||
remove backticks, normaliza (comentários/strings fora), e testa se algum nome de tabela
|
||||
protegida aparece como identificador real com word boundaries (`wp_users_backup` **não**
|
||||
corresponde a `wp_users`). Aplicado ao tool `query` para bloquear leitura directa de
|
||||
password hashes/tokens de sessão via `SELECT * FROM wp_users`.
|
||||
- **`before_image( $table, $where )`** — `SELECT *` com condições de igualdade AND
|
||||
(parametrizado via `$wpdb->prepare`), `LIMIT 500`, chamado antes de update/delete.
|
||||
- **`log()`** — `@deprecated 3.10.0`, no-op; ledger via `EMCP_Tools_Change_Recorder::record_db()`.
|
||||
|
||||
**Storage:** nenhum armazenamento próprio — escreve directamente nas tabelas alvo via `$wpdb`;
|
||||
before-images e rollback vão para o change ledger unificado (blob store para snapshots grandes,
|
||||
ver doc 05).
|
||||
|
||||
---
|
||||
|
||||
## 3. WP-CLI (execução + jobs assíncronos)
|
||||
|
||||
**Classe de abilities:** `EMCP_Tools_WPCLI_Abilities` (`includes/abilities/class-wpcli-abilities.php`)
|
||||
**Condição de registo:** sempre activo. **Todas as 4 tools deste grupo fazem parte dos 131
|
||||
slugs desligados por omissão** (`skill://emcp-tools` §2.1) — incluindo as duas de puro leitura
|
||||
(`get-wp-cli-job`/`list-wp-cli-jobs`), ao contrário de outros grupos onde read/write têm gates
|
||||
separadas. Provavelmente porque estas duas só fazem sentido em conjunto com `run`/`dispatch`.
|
||||
**`permission_callback` (todas as 4):** `current_user_can( 'manage_options' )`.
|
||||
|
||||
Comentário do cabeçalho do ficheiro, citado por ser exactamente o resumo de risco correcto:
|
||||
*"Risk notice. WP-CLI is powerful and, via the shell path, is effectively command execution.
|
||||
The tool is confined by a command blocklist (no eval, eval-file, shell, raw db query, config
|
||||
writes, package install, or arbitrary PHP flags), ships disabled-by-default, is admin-gated, and
|
||||
audit-logs runs."*
|
||||
|
||||
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|
||||
|---|---|---|---|
|
||||
| `run-wp-cli` | `command` (string, **required**, sem `wp` inicial), `timeout` (int, default 60, tecto 300) | Corre um comando WP-CLI síncrono. Devolve stdout/stderr/exit_code. Executa **in-process** (`WP_CLI::runcommand`) se este pedido já corre dentro de um processo WP-CLI (transporte stdio), ou via **shell** (`proc_open` com um binário `wp` configurado) se ligado por HTTP. | **destrutivo** (por defeito de anotação — pode invocar qualquer subcomando não bloqueado) — **desligado por omissão** |
|
||||
| `dispatch-wp-cli` | `command` (**required**), `timeout` (int, default 900, conselho até 86400) | Corre como job **detached** em background (para migrações/bulk tasks longas). Requer o caminho shell disponível — não é possível fazer detach de um comando in-process. Devolve `job_id`. | **destrutivo** — **desligado por omissão** |
|
||||
| `get-wp-cli-job` | `job_id` (string, **required**) | Devolve estado do job (running/completed/failed), exit_code, stdout/stderr (tail capado). | readonly (anotação) — **mesmo assim desligado por omissão** |
|
||||
| `list-wp-cli-jobs` | (sem input) | Lista jobs recentes (metadata apenas, sem stdout/stderr completos). | readonly (anotação) — **mesmo assim desligado por omissão** |
|
||||
|
||||
Cada execução (`run` e `dispatch`) é registada no change ledger, mas **não é reversível** —
|
||||
comentário explícito no código: *"not reversible — commands have no before-image"*.
|
||||
|
||||
### Validator: `EMCP_Tools_WPCLI_Validator`
|
||||
|
||||
Ficheiro: `includes/wpcli/class-wpcli-validator.php`. **A gate de segurança do grupo inteiro.**
|
||||
Comentário do cabeçalho: *"Args are always passed to the runner as an array (never interpolated
|
||||
into a shell string), so metacharacters in values are inert; this validator blocks the command
|
||||
surface that would let an operator run arbitrary PHP, raw SQL, or arbitrary shell."*
|
||||
|
||||
- **`BLOCKED_COMMANDS`** = `eval`, `eval-file`, `shell`, `server` — comandos WP-CLI que dão
|
||||
execução de PHP arbitrário, shell interactivo, ou arrancam um servidor web embutido.
|
||||
- **`BLOCKED_SUBCOMMANDS`** (pares `comando subcomando`) = `db query`, `db cli`, `db import`,
|
||||
`db export`, `db reset`, `db drop`, `db clean`, `config set`, `config delete`, `config edit`,
|
||||
`package install`, `package update`, `package uninstall`, `cli update`, `cli cmd-dump`,
|
||||
`cli info`. Note-se: `db query`/`db cli`/etc. é redundante em parte com a gate própria da
|
||||
ability `database` (§2), mas é defesa em profundidade — um agente não pode contornar o guard
|
||||
de SQL via WP-CLI.
|
||||
- **`BLOCKED_FLAG_PREFIXES`** = `--exec`, `--require` (carregam PHP arbitrário), `--path`,
|
||||
`--ssh`, `--http` (retargeting do WP-CLI para outro install/servidor — poderia escapar do
|
||||
site actual), `--prompt` (ficaria pendurado à espera de input interactivo), `--user=0`.
|
||||
- **`validate( $command )`**: `trim()`; rejeita `\r`/`\n` (anti-injecção de linha); remove
|
||||
prefixo `"wp "` tolerado; tokeniza; varre TODOS os tokens contra os prefixos de flag
|
||||
bloqueados (`stripos`, case-insensitive, por prefixo — não exact-match); identifica a
|
||||
"command word" = primeiro token não-flag (não começa por `-`) e o subcommand = segundo token
|
||||
não-flag; rejeita se o comando isolado ou o par comando+subcomando estiverem nas listas.
|
||||
Todas as três listas são filtráveis (`emcp_tools_wpcli_blocked_commands`,
|
||||
`_blocked_subcommands`, `_blocked_flags`).
|
||||
- **`tokenize( $command )`**: tokenizador consciente de aspas (single/double quotes). Fora de
|
||||
aspas, um backslash é **literal** (friendly para paths Windows tipo `C:\wp\wp-cli.phar`).
|
||||
Dentro de aspas duplas, só `\"` e `\\` são escapes reconhecidos; dentro de aspas simples,
|
||||
nada é escapado. Devolve `WP_Error('wpcli_unterminated_quote')` se uma aspa não fechar.
|
||||
|
||||
### Runner: `EMCP_Tools_WPCLI_Runner`
|
||||
|
||||
Ficheiro: `includes/wpcli/class-wpcli-runner.php`. `OUTPUT_CAP = 262144` (256 KB) por stream,
|
||||
truncado com `"…[output truncated]"`.
|
||||
|
||||
- **`is_cli_context()`** — `defined('WP_CLI') && WP_CLI && class_exists('\WP_CLI')` — true se
|
||||
este pedido já corre dentro de um processo WP-CLI (o caso normal deste ecossistema, que liga
|
||||
via SSH+STDIO a `wp mcp-adapter serve`, ver doc 00 e `skill://emcp-tools`).
|
||||
- **`base_command()`** — o binário `wp` configurado, ordem de prioridade: constante
|
||||
`EMCP_TOOLS_WPCLI_COMMAND` > option `emcp_tools_wpcli_command` > filtro
|
||||
`emcp_tools_wpcli_command`.
|
||||
- **`shell_available()`** — `proc_open` existe, `base_command()` não vazio, e `proc_open` não
|
||||
está em `disable_functions` do php.ini.
|
||||
- **`run( $command, $timeout=60 )`** — valida via `Validator::validate()`; se `is_cli_context()`
|
||||
→ `run_in_process()` usa `WP_CLI::runcommand($cmd, ['return'=>'all','exit_error'=>false,'launch'=>false,'parse'=>false])`
|
||||
— corre **dentro do mesmo processo PHP**, sem `fork`/`exec`. Senão, se `shell_available()` →
|
||||
`run_shell()`.
|
||||
- **`run_shell()`** — `proc_open($argv_array, ...)`. Comentário do código, crucial:
|
||||
*"PHP 7.4+: an array command is executed WITHOUT a shell — arguments are passed verbatim, so
|
||||
no metacharacter can be interpreted."* `$argv = base_argv() + tokens + ['--path=' . ABSPATH, '--no-color']`.
|
||||
Poll não-bloqueante (`stream_set_blocking(false)`), timeout com `proc_terminate($proc, 9)`
|
||||
(SIGKILL) se ultrapassar deadline (`timed_out=true`, `exit_code=124`), drena pipes no fim.
|
||||
|
||||
### Jobs assíncronos: `EMCP_Tools_WPCLI_Jobs`
|
||||
|
||||
Ficheiro: `includes/wpcli/class-wpcli-jobs.php`. `KEEP = 50` job dirs mantidos (mais antigos são
|
||||
apagados por `prune()`, por `filemtime`).
|
||||
|
||||
- **`dir()`** — `wp-content/uploads/emcp-wpcli-jobs/`, criado + protegido na primeira utilização
|
||||
com `.htaccess` (`Require all denied\nDeny from all`) + `index.php` silencioso.
|
||||
- **Estrutura por job** (`<id>/`, `$id = gmdate('Ymd-His') . '-' . substr(md5(uniqid()),0,6)`):
|
||||
- `meta.json` — `{id, command, timeout, status, created, started, finished, exit_code, user}`;
|
||||
`status` transita `queued → running → (completed|failed)`.
|
||||
- `stdout.log` / `stderr.log` — streams capturados.
|
||||
- `run.sh` (POSIX) ou `run.bat` (Windows) — **launcher gerado**, com o comando completo já
|
||||
montado via `escapeshellarg()` por token (`implode(' ', array_map('escapeshellarg', $argv))`)
|
||||
— a escaping fica de fora do `proc_open`/`popen` de spawn.
|
||||
- `exit_code` — ficheiro escrito **pelo launcher** quando o comando termina — é a fonte de
|
||||
verdade para o estado terminal (não um polling do processo pai).
|
||||
- **`dispatch( $command, $timeout=900 )`** — requer `shell_available()` (impossível fazer
|
||||
detach de algo já in-process); valida; `prune()`; grava `meta.json` inicial; `spawn()` lança
|
||||
o launcher **detached** — POSIX: `proc_open(['sh','-c', 'nohup sh run.sh > /dev/null 2>&1 &'], ...)`;
|
||||
Windows: `popen('cmd /c start /B "" cmd /c run.bat', 'r')`. O processo pai **não espera** —
|
||||
retorna `job_id` de imediato com `status='running'`.
|
||||
- **`get( $id )`** — sanitiza `$id` (regex `[^a-z0-9-]` removido); lê `meta.json`; deriva o
|
||||
estado terminal do ficheiro `exit_code` se existir (`0 = completed`, `!=0 = failed`); devolve
|
||||
`tail()` do stdout/stderr (capado a `OUTPUT_CAP`, prefixo `"…[output truncated]"` — mostra o
|
||||
**fim** do log, não o início).
|
||||
- **`all()`** — todos os jobs (`glob(GLOB_ONLYDIR)`), ordenados por `created` desc, sem
|
||||
stdout/stderr completos.
|
||||
- **`spawn()`** — vale a pena ler: gera o `run.sh`/`run.bat` completo primeiro (incluindo
|
||||
redirecção de stdout/stderr/exit_code), depois só lança um shell trivial que executa esse
|
||||
ficheiro — separa completamente a lógica de "o que corre" da lógica de "como fica detached".
|
||||
|
||||
**Storage:** `wp-content/uploads/emcp-wpcli-jobs/<job-id>/` (protegido de acesso web).
|
||||
|
||||
---
|
||||
|
||||
## 4. Security & Malware Scanner
|
||||
|
||||
**Classe de abilities:** `EMCP_Tools_Security_Abilities` (`includes/abilities/class-security-abilities.php`)
|
||||
**Condição de registo:** sempre activo. **`scan-security` está ACTIVA por omissão** (não faz
|
||||
parte do deny-list — a única categoria deste documento com essa distinção, junto com
|
||||
`analyze-performance`).
|
||||
**`permission_callback`:** `current_user_can( 'manage_options' )`.
|
||||
|
||||
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|
||||
|---|---|---|---|
|
||||
| `scan-security` | `checks` (array de enum `malware`/`integrity`/`hardening`/`software`, opt — omitir corre as 4), `deep` (bool, default false), `max_files` (int, default 2000, tecto 20000), `max_seconds` (int, default 20, tecto 120) | Corre até 4 audits e devolve `{summary:{score 0-100, grade A-F, counts}, sections:{malware,integrity,hardening,software}, scan_meta, top_recommendations}`. `deep=false` cobre só `uploads/` + plugins activos + tema activo; `deep=true` cobre toda a `wp-content/` (mais lento). | **readonly, destructive=false, idempotent=true — activa por omissão** |
|
||||
|
||||
### Orchestrator: `EMCP_Tools_Security_Scanner`
|
||||
|
||||
Ficheiro: `includes/security/class-security-scanner.php`.
|
||||
`CRITICAL_WEIGHT=20`, `WARNING_WEIGHT=5`, `CATEGORY_CRIT_CAP=60` (o penalty de criticals
|
||||
**satura a 60 por categoria** — impede que uma categoria sozinha com muitos criticals leve o
|
||||
score a zero), `TOP_RECS=8`.
|
||||
|
||||
- **Construção LAZY dos 4 audits** — só instanciados na primeira `scan()` que de facto precisa
|
||||
deles. Decisão de design explícita (comentário completo citado em §4.1 abaixo, ligado ao
|
||||
**issue #100**): registar a tool **não pode** instanciar o motor de audit, porque o registo de
|
||||
abilities corre em **cada** carregamento de página de admin e **cada** pedido REST.
|
||||
- **`resolve_checks( $requested )`** — pure: normaliza para o subset válido em ordem canónica;
|
||||
vazio/tudo-inválido → todos os 4.
|
||||
- **`scan( $input )`** — corre os checks pedidos, agrega findings, `summarize()`.
|
||||
- **`summarize( $findings )`** — pure: conta por `status` (critical/warning/pass/info); penalty
|
||||
**por categoria** — `cat_crit_pen[cat] = min(60, soma_de_20_por_cada_critical_nessa_categoria)`;
|
||||
`score = 100 - soma(penalties_por_categoria) - (nº_warnings * 5)`, clamp `[0,100]`;
|
||||
`grade` A(≥90)/B(≥80)/C(≥70)/D(≥60)/F(resto).
|
||||
- **`group_by_category`** — agrupa em 4 secções fixas.
|
||||
- **`rank_recommendations`** — críticos primeiro, depois warnings, corta a 8, formato
|
||||
`"[label] recomendação"`.
|
||||
|
||||
### Value object: `EMCP_Tools_Security_Finding`
|
||||
|
||||
Ficheiro: `includes/security/class-security-finding.php`. Uma única factory pure:
|
||||
`make( id, category, label, status, value, message, recommendation='' )` → array uniforme.
|
||||
`status` ∈ `pass|warning|critical|info`; `recommendation` deve ser não-vazio quando `status !=
|
||||
'pass'`.
|
||||
|
||||
### 4.1 Audit — Malware (`EMCP_Tools_Security_Malware_Audit`)
|
||||
|
||||
Ficheiro: `includes/security/class-security-malware-audit.php`.
|
||||
`MAX_FILE_BYTES=2MB` (ficheiros maiores são saltados), `MAX_LINE_BYTES=64KB` (cap por linha
|
||||
alimentada às regex — guarda anti-ReDoS/backtrack-limit; o comentário explica: linhas longas
|
||||
fazem `preg_match` devolver `false` silenciosamente ao atingir `pcre.backtrack_limit`, mascarando
|
||||
um hit real), `MAX_FILES=2000`/`CEILING=20000`, `TIME_BUDGET=20s`/`CEILING=120s`,
|
||||
`SNIPPET_LEN=120`, `MAX_FINDINGS_PER_FILE=5`.
|
||||
|
||||
- **`scan_code( code, relpath, in_uploads )`** — pure: corre 5 regras de assinatura linha a
|
||||
linha (ver abaixo); nunca devolve mais de 5 achados por ficheiro; o `value` de cada finding é
|
||||
`{location: "path:line", snippet}` — **nunca o conteúdo completo do ficheiro**.
|
||||
- **`is_misplaced_php( relpath )`** — PHP executável dentro de `uploads/` (extensões
|
||||
`php/phtml/php3-7/phps/pht`).
|
||||
- **`is_trivial_php( code )`** — pure, usa `token_get_all()` para distinguir um
|
||||
`index.php` "Silence is golden" (só `T_OPEN_TAG`/`CLOSE_TAG`/`WHITESPACE`/`COMMENT`/`DOC_COMMENT`)
|
||||
de PHP com código real — evita falsos positivos em guardas de directório vazias.
|
||||
- **`is_excluded( relpath, prefixes )`** — exclui a própria pasta de instalação do plugin (via
|
||||
`EMCP_TOOLS_DIR`) + o directório sandbox gerido (`EMCP_Tools_Sandbox_Paths::relative_base()`),
|
||||
para o scanner não se auto-detectar como malware.
|
||||
- **`run( deep, max_files, max_seconds )`** — `scan_roots(deep)`: `false` →
|
||||
`uploads/` + plugins **activos** + tema activo/pai; `true` → toda a `wp-content/`. Percorre com
|
||||
`RecursiveIteratorIterator` + `FOLLOW_SYMLINKS`, mas **valida que o caminho resolvido
|
||||
continua dentro de `ABSPATH`** (bloqueia escape de symlink). Ficheiro PHP executável sob
|
||||
`uploads/` com código real (não trivial) gera um achado crítico dedicado
|
||||
`malware_uploads_php` **antes** de correr as 5 regras normais.
|
||||
|
||||
**🏆 O achado mais importante deste ficheiro (citação literal, comentário do autor):**
|
||||
|
||||
As assinaturas de malware (`eval`, `assert`, `create_function`, `base64_decode`, `gzinflate`,
|
||||
`system`, `exec`, `shell_exec`, `c99shell`, `r57shell`, `b374k`, `phpspy`, `WSO`, etc.) **NÃO
|
||||
estão escritas de forma literal no ficheiro-fonte** — estão fragmentadas em
|
||||
`signature_tokens()` como concatenações (`'ev' . 'al'`), reunidas em runtime via
|
||||
`expand()`/`strtr()`. Comentário do autor no código:
|
||||
|
||||
> *"This class is a malware scanner, so its rules have to name the exact functions and
|
||||
> webshell handles that host-level scanners (Imunify360, maldet, Wordfence, ModSecurity) hunt
|
||||
> for. Spelled out intact, this file reads as a c99-style webshell and gets quarantined or
|
||||
> zeroed in place. `require_once` then still succeeds (the path exists) but the class is never
|
||||
> declared, which used to fatal every wp-admin page and REST request (issue #100)."*
|
||||
|
||||
> *"Splitting the tokens means no intact signature ever sits on disk. The patterns compiled
|
||||
> below are byte-identical to the originals, so detection behaviour is unchanged. Keep any new
|
||||
> signature split the same way."*
|
||||
|
||||
Este é o motivo directo por trás **tanto** da construção lazy no orchestrator (§4, issue #100)
|
||||
**como** desta fragmentação de tokens: um scanner de malware do **próprio host** identificava
|
||||
literalmente este ficheiro como um webshell e colocava-o em quarentena/zerava-o, partindo o site
|
||||
inteiro (a classe deixava de existir mas o `require_once` continuava a "ter sucesso"
|
||||
silenciosamente — o registo de abilities engolia a excepção mas o servidor MCP ficava sem esta
|
||||
tool).
|
||||
|
||||
**As 5 regras de assinatura:**
|
||||
1. `malware_eval_obfuscation` (critical) — `eval`/`assert`/`create_function` envolvendo um
|
||||
decoder (`base64_decode`/`gzinflate`/`gzuncompress`/`str_rot13`/`strrev`/`convert_uudecode`).
|
||||
2. `malware_request_eval` (critical) — `eval`/`assert`/`system`/`exec`/`passthru`/
|
||||
`shell_exec`/`popen`/`proc_open` recebendo directamente `$_GET`/`POST`/`REQUEST`/`COOKIE`/`SERVER`
|
||||
— o backdoor RCE clássico.
|
||||
3. `malware_command_exec` (warning; **critical se `in_uploads`**) — qualquer chamada de
|
||||
`shell_exec`/`passthru`/`proc_open`/`popen`/`system`/`exec` isolada.
|
||||
4. `malware_webshell_marker` (critical) — strings de webshells conhecidos
|
||||
(`FilesMan`, `c99shell`, `r57shell`, `b374k`, `phpspy`, `WSO<versão>shell`).
|
||||
5. `malware_long_base64` (warning) — blob de 260+ chars base64-like (payload escondido).
|
||||
|
||||
### 4.2 Audit — Integrity (`EMCP_Tools_Security_Integrity_Audit`)
|
||||
|
||||
Ficheiro: `includes/security/class-security-integrity-audit.php`.
|
||||
|
||||
- **`diff( checksums, hasher )`** — pure: compara o manifesto de checksums oficial do
|
||||
wordpress.org (md5 por ficheiro core-relative) contra o hash real via `hash_equals()`
|
||||
(timing-safe); ficheiro em falta → `warning('integrity_missing')`; hash não bate →
|
||||
`critical('integrity_modified')`.
|
||||
- **`run()`** — usa a função core `get_core_checksums($wp_version, $locale)`
|
||||
(`wp-admin/includes/update.php`); **exclui tudo em `wp-content/`** (não faz parte dos
|
||||
checksums oficiais de core). Se a API estiver inacessível (offline) devolve **um único**
|
||||
finding `info` com `api.ok=false` — degrada graciosamente em vez de falhar todo o scan.
|
||||
|
||||
### 4.3 Audit — Hardening (`EMCP_Tools_Security_Hardening_Audit`)
|
||||
|
||||
Ficheiro: `includes/security/class-security-hardening-audit.php`. `FETCH_TIMEOUT=8s`.
|
||||
|
||||
7 checks, cada `evaluate_*()` pure, `run()` gathers live + **UM** loopback GET reutilizado para
|
||||
dois checks (headers + generator meta):
|
||||
|
||||
| Check | Pass | Warning/Critical |
|
||||
|---|---|---|
|
||||
| `harden_file_edit` | `DISALLOW_FILE_EDIT` definido true | editor de ficheiros do admin ligado |
|
||||
| `harden_debug_display` | `WP_DEBUG_DISPLAY` off | `warning` se on em produção; `info` noutro ambiente |
|
||||
| `harden_admin_user` | sem user `admin` | `username_exists('admin')` |
|
||||
| `harden_xmlrpc` | XML-RPC desligado | `xmlrpc.php` existe + filtro `xmlrpc_enabled` true |
|
||||
| `harden_https` | scheme de `home_url()` é `https` | qualquer outro |
|
||||
| `harden_security_headers` | X-Frame-Options + X-Content-Type-Options + Strict-Transport-Security + Content-Security-Policy todos presentes | falta pelo menos um (via 1 loopback GET a `home_url('/')`) |
|
||||
| `harden_version_disclosure` | sem `readme.html` nem meta `generator` no HTML | qualquer um presente |
|
||||
|
||||
### 4.4 Audit — Software (`EMCP_Tools_Security_Software_Audit`)
|
||||
|
||||
Ficheiro: `includes/security/class-security-software-audit.php`.
|
||||
`MAX_ABANDONED_LOOKUPS=30` (orçamento de chamadas `plugins_api` **ao vivo** por scan),
|
||||
`ABANDONED_CACHE_TTL=43200` (12h, transient por-slug).
|
||||
|
||||
Checks: core desactualizado, plugins/temas desactualizados (um finding por item), plugins
|
||||
inactivos (contagem — `info`, não `warning`), **plugins abandonados/removidos do directório
|
||||
wordpress.org** — via `plugins_api('plugin_information', ['slug'=>$slug])` e o campo
|
||||
`$info->closed`. O resultado (`closed`/`open`) fica em cache num transient
|
||||
`emcp_sec_abandoned_<md5(slug)>` durante 12h; um `WP_Error` (plugin premium não no wp.org, ou API
|
||||
em baixo) **não é marcado nem colocado em cache** — retenta no próximo scan. Slugs já em cache
|
||||
não contam para o orçamento de 30 chamadas ao vivo — só chamadas realmente feitas são limitadas.
|
||||
|
||||
---
|
||||
|
||||
## 5. Performance Analyzer
|
||||
|
||||
**Classe de abilities:** `EMCP_Tools_Performance_Abilities` (`includes/abilities/class-performance-abilities.php`)
|
||||
**Condição de registo:** sempre activo. **`analyze-performance` está ACTIVA por omissão**
|
||||
(igual a `scan-security`).
|
||||
**`permission_callback`:** `current_user_can( 'manage_options' )`.
|
||||
|
||||
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|
||||
|---|---|---|---|
|
||||
| `analyze-performance` | `url` (uri, opt — página deste site, hosts externos rejeitados), `post_id` (int, opt — ignorado se `url` definido), `include_page_fetch` (bool, default true — `false` corre só server/DB), `deep_assets` (bool, **reservado, ainda não implementado**) | Sem `url`/`post_id` analisa a frontpage. Devolve `{target, summary{score,grade,counts}, sections, page_fetch, top_recommendations}`. | **readonly, destructive=false, idempotent=true — activa por omissão** |
|
||||
|
||||
### Orchestrator: `EMCP_Tools_Performance_Analyzer`
|
||||
|
||||
Ficheiro: `includes/performance/class-performance-analyzer.php`.
|
||||
`CRITICAL_WEIGHT=15`, `WARNING_WEIGHT=4`, `TOP_RECS=8` (pesos diferentes do Security Scanner —
|
||||
mais leve, **sem** cap por categoria).
|
||||
|
||||
- **`analyze( $input )`** — `resolve_target()` primeiro (`url`|`post_id`|frontpage); corre
|
||||
**sempre** o server audit; corre o page audit condicionalmente
|
||||
(`include_page_fetch`); combina findings; `summarize()` + `group_by_category()`.
|
||||
- **`resolve_target( $input )`** — valida que `url` está no **mesmo host** que `home_url()` via
|
||||
`validate_same_host()` (pure) — protecção anti-SSRF/anti-scan-de-outro-site logo à entrada.
|
||||
- **`summarize( $findings )`** — pure: `score = 100 - (critical*15) - (warning*4)`, **sem** cap
|
||||
por categoria (diferente do Security Scanner); grade A-F igual.
|
||||
- **`group_by_category`** — 5 secções fixas: `server`, `database`, `config`, `page`, `assets`.
|
||||
|
||||
### Value object: `EMCP_Tools_Performance_Finding`
|
||||
|
||||
Ficheiro: `includes/performance/class-performance-finding.php`. Idêntico em forma ao
|
||||
`Security_Finding` — factory pure `make(id, category, label, status, value, message, recommendation='')`.
|
||||
|
||||
### 5.1 Audit — Server (`EMCP_Tools_Performance_Server_Audit`)
|
||||
|
||||
Ficheiro: `includes/performance/class-performance-server-audit.php`. **Todo in-process, sem
|
||||
HTTP.** `MIN_MEMORY_BYTES=128MB`, `AUTOLOAD_WARN=1MB`/`CRIT=3MB`, `PLUGIN_WARN_COUNT=40`,
|
||||
`REVISIONS_WARN_COUNT=1000`, `TOP_TABLES=5`, `TOP_AUTOLOAD_OPTIONS=5`.
|
||||
|
||||
11 checks pure+live: **versão PHP** (≥8.2 pass, ≥8.0 warning, senão critical), **memory_limit**
|
||||
(≥128MB pass, `-1`=ilimitado=pass, senão warning), **OPcache** activo, **object cache
|
||||
persistente** (`wp_using_ext_object_cache()`), **biblioteca de imagem** (Imagick ou GD),
|
||||
**WP_DEBUG** em produção (warning) vs outros ambientes (info), **contagem de plugins activos**
|
||||
(>40 → warning), **revisões de posts** (>1000 → warning, `COUNT(*) WHERE post_type='revision'`),
|
||||
**backlog de cron** overdue >5min (via `_get_cron_array()`), **tamanho de opções autoload**
|
||||
(`SUM(LENGTH(option_value)) WHERE autoload IN ('yes','on','auto')` — nota: cobre as **3
|
||||
variantes** de valor da coluna `autoload`, não só `'yes'`; devolve top 5 maiores), **tamanho da
|
||||
base de dados** (`SUM(data_length+index_length)` de `information_schema.TABLES`, top 5 tabelas
|
||||
maiores).
|
||||
|
||||
### 5.2 Audit — Page (`EMCP_Tools_Performance_Page_Audit`)
|
||||
|
||||
Ficheiro: `includes/performance/class-performance-page-audit.php`. **1 loopback HTTP fetch +
|
||||
parsing DOM. Não executa JS** — análise de HTML/headers, não Core Web Vitals reais.
|
||||
`FETCH_TIMEOUT=10s`, `MAX_HTML_BYTES=2MB` (cap de parsing), `MAX_REDIRECTS=3`,
|
||||
`RESPONSE_WARN_MS=800`, `HTML_WARN_BYTES=500KB`, `RENDER_BLOCK_WARN=5`.
|
||||
|
||||
- **`fetch( $url, $timeout=10 )`** — segue redirects **manualmente** (`redirection=0` em cada
|
||||
`wp_remote_get`, loop até `MAX_REDIRECTS`) **em vez de** delegar no `redirection` nativo do
|
||||
`wp_remote_get`. Motivo: cada hop precisa de ser revalidado contra o host de origem
|
||||
(`safe_redirect_target()`) — um redirect para outro host é **recusado**, não seguido.
|
||||
- **`safe_redirect_target( location, current_url, origin_host )`** — resolve `Location`
|
||||
relativo contra o URL actual (scheme+host), depois testa se o host de destino ==
|
||||
`origin_host` (case-insensitive); devolve `''` se sair do host. **Guarda anti-SSRF
|
||||
explícita**: um atacante não pode fazer o site "chutar" outro host arbitrário através de um
|
||||
redirect 301/302 configurado maliciosamente numa página que este tool analisa.
|
||||
- **`analyze( $fetched, $deep_assets )`** — pure. Se o fetch falhou → 1 finding `warning` e
|
||||
degrada graciosamente (server/DB continuam reportados normalmente). Se OK: `http_status`
|
||||
(200=pass), `response_time` (>800ms=warning), `html_size` (>500KB=warning), `compression`
|
||||
(gzip/br em `content-encoding`), `cache_headers` (Cache-Control/Expires/X-Cache presentes),
|
||||
depois `parse_dom()` com `DOMDocument` (`libxml_use_internal_errors` para engolir HTML
|
||||
malformado) + `asset_findings()`: **render-blocking** (`<link rel=stylesheet>` no `<head>` +
|
||||
`<script>` síncrono no `<head>` sem `async`/`defer`, warning se >5), **asset_counts** (info),
|
||||
**image_lazy_loading** (contagem de `<img>` sem `loading="lazy"`, info), **third_party**
|
||||
(domínios de `<link>`/`<script src>` diferentes do host analisado, info).
|
||||
|
||||
**Storage:** nenhum armazenamento próprio para nenhum dos dois audits — tudo calculado ao vivo e
|
||||
devolvido na resposta; nada persistido em disco/BD (excepto os transients de cache do audit de
|
||||
software do Security Scanner, que é um documento diferente — §4.4).
|
||||
|
||||
---
|
||||
|
||||
## Blueprint para réplica
|
||||
|
||||
### Copiar quase 1:1 (código já correcto, difícil de reproduzir sem reintroduzir bugs)
|
||||
|
||||
1. **`EMCP_Tools_Filesystem_Guard::resolve_path()`** — o chokepoint de confinamento a `ABSPATH`.
|
||||
A lógica de comparação de prefixo (`===` OU `strpos($real, $root . DIRECTORY_SEPARATOR) === 0`,
|
||||
nunca um `strpos` simples) evita a classe de bugs "`/var/www/site-evil` passa como prefixo de
|
||||
`/var/www/site`". O tratamento de caminhos que ainda não existem (resolver o **pai** com
|
||||
`realpath` para permitir criar um ficheiro novo) é subtil e vale a pena copiar tal-e-qual.
|
||||
2. **A distinção `is_protected` (write) vs `is_read_protected` (read)** — não são a mesma lista.
|
||||
`.htaccess` pode ser lido mas não escrito/apagado; `wp-config.php` não pode nem sequer ser
|
||||
lido. Uma réplica que use uma única lista "protected" para tudo está a proteger a menos (deixa
|
||||
ler segredos) ou a mais (bloqueia leitura de `.htaccess`, que é inofensiva e útil de auditar).
|
||||
3. **Padrão "backup antes de escrever, directório de backups bloqueado a acesso web"** —
|
||||
`.htaccess Require all denied` + `index.html` vazio no directório de backups, gerado on-demand
|
||||
na primeira escrita. Simples e eficaz; copiar tal-e-qual.
|
||||
4. **`EMCP_Tools_Database_Guard::normalize_sql()` + `is_read_only_sql()`** — o scanner
|
||||
char-a-char que neutraliza comentários/strings/identificadores antes de testar keywords é
|
||||
sofisticado e já tem dois bugs de "quase-passou" documentados directamente nos comentários do
|
||||
código (o truque `/*!` do MySQL, e o cuidado de não pôr `\b` a fechar no regex de
|
||||
`LOAD_FILE(`). Reimplementar do zero arrisca reintroduzir exactamente estes dois bugs já
|
||||
corrigidos aqui.
|
||||
5. **`valid_table()` a resolver contra `SHOW TABLES` ao vivo** em vez de confiar no input —
|
||||
nomes de tabela não podem ser parametrizados em SQL preparado, por isso a única forma segura
|
||||
de os validar é confirmá-los contra uma lista real.
|
||||
6. **`EMCP_Tools_WPCLI_Validator`** — a combinação de blocklist de comandos/subcomandos/flags +
|
||||
tokenizador consciente de aspas (com as regras específicas de escaping por tipo de aspa, e o
|
||||
comportamento "backslash literal fora de aspas" para paths Windows) é bem pensada e testada.
|
||||
Copiar a estrutura (3 listas filtráveis + tokenizer) quase tal-e-qual.
|
||||
7. **O princípio "argv array, nunca string interpolada"** em `WPCLI_Runner::run_shell()` e
|
||||
`WPCLI_Jobs::spawn()` — mesmo quando o `spawn()` monta uma string via
|
||||
`implode(' ', array_map('escapeshellarg', $argv))` para o ficheiro launcher, cada TOKEN
|
||||
individual passa primeiro por `escapeshellarg()`; nunca há concatenação directa de input do
|
||||
utilizador numa string de shell. Esta é a regra arquitectural mais importante de todo este
|
||||
documento — qualquer tool nova que precise de invocar um processo externo deve seguir o mesmo
|
||||
padrão.
|
||||
8. **A técnica de fragmentação de assinaturas do malware scanner** (`signature_tokens()` +
|
||||
`expand()`) — o achado mais interessante de todo o ficheiro: se uma réplica alguma vez
|
||||
escrever o seu próprio scanner de malware, tem de aplicar a mesma técnica ou arrisca ser
|
||||
confundida com o próprio malware que procura, por scanners de host (Imunify360, maldet,
|
||||
Wordfence). Ver citação completa em §4.1.
|
||||
9. **`Performance_Page_Audit::fetch()` / `safe_redirect_target()`** — seguir redirects
|
||||
manualmente em vez de delegar no parâmetro `redirection` do `wp_remote_get`, revalidando o
|
||||
host em cada hop. Este padrão é reutilizável para qualquer futura tool "analisar um URL deste
|
||||
site" (ex. um crawler de sitemap) — nunca confiar que o WordPress core segue redirects de
|
||||
forma segura para este caso de uso.
|
||||
|
||||
### Simplificar
|
||||
|
||||
1. **O caminho "shell" completo de WP-CLI (binário `wp` configurável via option/constante,
|
||||
`proc_open` HTTP, jobs em background detached)** é infraestrutura pesada que só é necessária
|
||||
quando o servidor MCP é alcançado por HTTP puro (sem contexto WP-CLI). **Neste ecossistema, os
|
||||
3 servidores MCP já ligam via SSH+STDIO a `wp mcp-adapter serve` (ver doc 00 e
|
||||
`skill://emcp-tools`)** — ou seja, `is_cli_context()` é **sempre verdadeiro** e o caminho
|
||||
`run_in_process()` cobre 100% da utilização real. Uma réplica focada neste padrão de
|
||||
deployment pode **eliminar inteiramente** o caminho shell + jobs detached (`WPCLI_Runner::run_shell`,
|
||||
toda a classe `WPCLI_Jobs`) e ficar só com `WP_CLI::runcommand(..., launch=false)` — muito
|
||||
menos superfície de ataque e código para manter, sem perder funcionalidade no cenário real de
|
||||
uso.
|
||||
2. **`Security_Finding`/`Performance_Finding`** são factories triviais de um único método —
|
||||
copiar tal-e-qual sem redesenho, não há aqui nada a simplificar mais.
|
||||
3. O cap `CATEGORY_CRIT_CAP=60` por categoria no Security Scanner é razoável mas arbitrário —
|
||||
uma réplica pode adoptar o mesmo valor sem re-derivar a heurística, ou simplesmente usar o
|
||||
scoring mais simples do Performance Analyzer (sem cap) se a diferenciação por categoria não
|
||||
for um requisito.
|
||||
|
||||
### Deixar de fora (ou adiar)
|
||||
|
||||
1. **`deep_assets` no Performance Analyzer** — está no schema como "reserved", nunca chegou a ser
|
||||
implementado neste build. Não vale a pena reservar espaço para uma feature que o próprio
|
||||
vendor não implementou em 12 versões (`since 3.0.0`).
|
||||
|
||||
### Gotchas não óbvios a não esquecer
|
||||
|
||||
- **Invalidação de OPcache após escrever/apagar `.php`** (`opcache_invalidate($abs, true)`) — é
|
||||
fácil esquecer este passo numa réplica e ter bugs "a alteração não teve efeito" reportados como
|
||||
falsos negativos de teste.
|
||||
- **O deny-list incremental (doc 00 §8) é a camada EXTERNA de defesa; os guards deste documento
|
||||
são a camada INTERNA.** Mesmo que uma réplica não implemente um deny-list configurável, TODAS
|
||||
as gates internas aqui documentadas (confinamento de path, validação de SQL, blocklist de
|
||||
WP-CLI, `confirm:true` obrigatório em deletes) devem ser mantidas — são a última linha de
|
||||
defesa se o deny-list for mal configurado (ver o achado crítico de `descomplicar.pt` no
|
||||
`skill://emcp-tools` §8.2, onde o deny-list externo falhou completamente e só as gates internas
|
||||
— que continuam a existir mas não bastam sozinhas quando a tool está "activa" — teriam impedido
|
||||
o pior).
|
||||
- **`get-wp-cli-job`/`list-wp-cli-jobs` são readonly e mesmo assim ficam desligados por omissão
|
||||
em bloco com `run`/`dispatch`** — é uma escolha deliberada de UX/segurança (não faz sentido dar
|
||||
acesso a resultados de jobs sem dar acesso a criá-los), mas quebra o padrão "read sempre
|
||||
activo, write desligado" que domina o resto do plugin. Documentar esta excepção explicitamente
|
||||
numa réplica para não ser "corrigida" por engano.
|
||||
- **A construção lazy dos audits de segurança não é só uma optimização de performance — é uma
|
||||
medida de resiliência anti-fatal-error** (issue #100). Qualquer réplica que registe abilities em
|
||||
cada `wp_abilities_api_init` (que corre em TODO pedido admin/REST) deve tratar a construção de
|
||||
qualquer dependência pesada/frágil da mesma forma: lazy, e sempre com um `try/catch` a envolver
|
||||
o registo global (ver `EMCP_Tools_Ability_Registrar::register_all()` no doc 00, que já faz
|
||||
exactamente isto ao nível do registrador inteiro).
|
||||
|
||||
---
|
||||
|
||||
## Fonte
|
||||
|
||||
Leitura directa (19-08-2026) de:
|
||||
`includes/abilities/class-filesystem-abilities.php`,
|
||||
`includes/class-filesystem-guard.php`,
|
||||
`includes/abilities/class-database-abilities.php`,
|
||||
`includes/class-database-guard.php`,
|
||||
`includes/abilities/class-wpcli-abilities.php`,
|
||||
`includes/wpcli/class-wpcli-runner.php`,
|
||||
`includes/wpcli/class-wpcli-validator.php`,
|
||||
`includes/wpcli/class-wpcli-jobs.php`,
|
||||
`includes/abilities/class-security-abilities.php`,
|
||||
`includes/security/class-security-scanner.php`,
|
||||
`includes/security/class-security-malware-audit.php`,
|
||||
`includes/security/class-security-hardening-audit.php`,
|
||||
`includes/security/class-security-software-audit.php`,
|
||||
`includes/security/class-security-integrity-audit.php`,
|
||||
`includes/security/class-security-finding.php`,
|
||||
`includes/abilities/class-performance-abilities.php`,
|
||||
`includes/performance/class-performance-analyzer.php`,
|
||||
`includes/performance/class-performance-server-audit.php`,
|
||||
`includes/performance/class-performance-page-audit.php`,
|
||||
`includes/performance/class-performance-finding.php`.
|
||||
|
||||
Cruzado com `includes/abilities/class-ability-registrar.php` (condições de registo — todos os 5
|
||||
grupos deste documento registam-se sem guarda condicional, fora do bloco `if ($elementor_active)`)
|
||||
e com `docs/00-ARQUITECTURA.md` + `skill://emcp-tools` (contexto de arquitectura geral e postura
|
||||
de deny-list ao vivo nos 3 sites do ecossistema).
|
||||
@@ -0,0 +1,457 @@
|
||||
# 08 — Integrações com plugins de terceiros (ACF, Meta Box, Forms, SEO, WooCommerce)
|
||||
|
||||
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
|
||||
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
|
||||
19-08-2026. Cobre as classes que ligam o EMCP Tools a dados/estruturas geridas por outros
|
||||
plugins: ACF, Meta Box, plugins de formulários (Contact Form 7 no Free) e plugins de SEO
|
||||
(Slim SEO no Free), mais o inventário de integrações Pro-only que a árvore Free referencia
|
||||
mas não contém (WooCommerce, 8 plugins de formulários adicionais, 6 plugins de SEO
|
||||
adicionais, 2 pacotes de widgets Elementor, e a Ultimate Addons for Elementor).
|
||||
|
||||
Contexto arquitectural (ver `docs/00-ARQUITECTURA.md`): toda ability regista-se via
|
||||
`emcp_tools_register_ability()` — nunca `wp_register_ability()` directamente —, que normaliza o
|
||||
schema, envolve o `execute_callback` num veto de escrita (`emcp_tools_before_write`) e num
|
||||
`normalize_result()` (garante retorno sempre objecto JSON), antes de delegar ao core. O gating
|
||||
de cada grupo vem de `includes/abilities/class-ability-registrar.php` (lido na íntegra nesta
|
||||
tarefa) — os excertos exactos são reproduzidos abaixo, não parafraseados.
|
||||
|
||||
## 0. Padrão comum às cinco integrações deste documento: o "dispatcher de dois braços"
|
||||
|
||||
Todas as cinco famílias (ACF, Meta Box, Forms, SEO — WooCommerce presumivelmente também,
|
||||
mas Pro) seguem a MESMA forma: em vez de registar N abilities MCP individuais (uma por
|
||||
operação), registam **exactamente duas** — um tool `<domínio>-read` e um tool `<domínio>-write` —
|
||||
cada uma com o schema `{ operation?: string, arguments?: object }`. Chamar sem `operation`
|
||||
devolve um catálogo de descoberta (`{ mode, operations: [{operation, description, ...}] }`);
|
||||
chamar com `operation` despacha para um executor interno. Isto é o MESMO padrão de "compact tool
|
||||
mode" que o dispatcher global de 3 tools usa ao nível do servidor inteiro (`docs/00`, §4) —
|
||||
aqui aplicado ao nível de CADA integração de terceiros, mantendo o catálogo total de abilities
|
||||
pequeno mesmo com dezenas de operações internas por plugin.
|
||||
|
||||
Duas implementações distintas deste padrão coexistem no código:
|
||||
|
||||
1. **ACF e Meta Box** — cada classe implementa o dispatcher "à mão" (método `dispatch()`
|
||||
privado + `operations()` que devolve o mapa nome→{mode,run,perm,desc,...}). Não há classe
|
||||
base partilhada entre ACF e Meta Box.
|
||||
2. **Forms e SEO** — têm uma classe base abstracta partilhada por TODAS as integrações da
|
||||
categoria (`EMCP_Tools_Form_Integration`, `EMCP_Tools_SEO_Integration`), da qual CF7 e Slim
|
||||
SEO (Free) e os 8+6 plugins Pro (não presentes nesta árvore) herdam. A base implementa
|
||||
`register()`, `dispatch()`, o catálogo de descoberta, o schema `{operation,arguments}` e uma
|
||||
gate de confirmação genérica (`confirm: true` obrigatório em `arguments` para operações
|
||||
marcadas `confirm=>true` no mapa de operações — mecanismo de "são irreversíveis" ainda por
|
||||
usar em CF7/Slim SEO Free, mas presumivelmente usado pelos plugins Pro com delete de
|
||||
entradas/redirects).
|
||||
|
||||
**Nuance de segurança repetida nas 4 classes**: o `permission_callback` registado no MCP (a
|
||||
"porta grossa" que decide se o tool sequer aparece/corre) é sempre uma capacidade **larga**
|
||||
(`edit_posts` para leitura; para escrita varia — ver tabelas abaixo). A gate **fina e real** por
|
||||
operação corre DENTRO do `dispatch()`, comparando com o `perm` específico de cada operação do
|
||||
mapa `operations()`. Um utilizador pode assim ver o tool `acf-write` listado mas ser recusado
|
||||
(`forbidden`) ao tentar `create-field-group` se só tiver `edit_posts` e não `manage_options`.
|
||||
|
||||
## 1. ACF (Advanced Custom Fields / ACF PRO)
|
||||
|
||||
**Ficheiro:** `includes/abilities/class-acf-abilities.php` (1627 linhas — a maior classe de
|
||||
abilities do plugin). **Classe:** `EMCP_Tools_ACF_Abilities`. **Desde:** 3.2.1.
|
||||
|
||||
**Condição de registo** (`class-ability-registrar.php`):
|
||||
```php
|
||||
// ACF abilities — only when Advanced Custom Fields (free or Pro) is active.
|
||||
if ( class_exists( 'EMCP_Tools_ACF_Abilities' ) && EMCP_Tools_ACF_Abilities::acf_active() ) {
|
||||
$acf = new EMCP_Tools_ACF_Abilities();
|
||||
$acf->register();
|
||||
...
|
||||
}
|
||||
```
|
||||
`acf_active()` = `function_exists('acf_get_field_groups')` (ACF free OU Pro, qualquer versão
|
||||
recente). Sem gate de licença Pro do EMCP em si — funciona com ACF free.
|
||||
|
||||
### 1.1 Abilities MCP registadas (2)
|
||||
|
||||
| Ability | Input schema | Descrição | `permission_callback` | readonly/destructive |
|
||||
|---|---|---|---|---|
|
||||
| `emcp-tools/acf-read` | `{ operation?: string, arguments?: object }` | Lê dados ACF: field groups, valores de campos, options pages, e (ACF 6.1+) CPTs/taxonomias geridos por ACF. Sem `operation` → catálogo. | `check_read_permission()` = `current_user_can('edit_posts')` | readonly=true, destructive=false, idempotent=true |
|
||||
| `emcp-tools/acf-write` | `{ operation?: string, arguments?: object }` | Escreve dados ACF (valores, field groups, CPTs/taxonomias). **Escritas desligadas por omissão** (activar em Tools → Plugins → ACF). Sem `operation` → catálogo. | `check_read_permission()` — **nota**: a gate registada no MCP é a mesma de leitura; a gate real de escrita (`manage_options`/`edit_post`) corre por operação dentro de `dispatch()` | readonly=false, destructive=false, idempotent=false |
|
||||
|
||||
### 1.2 As 15 operações internas (via `operation` + `arguments`)
|
||||
|
||||
| operação | modo | `perm` (capability) | requer ACF 6.1+? | O que faz |
|
||||
|---|---|---|---|---|
|
||||
| `list-field-groups` | read | `check_read_permission` (`edit_posts`) | não | Lista field groups: `key, id, title, active, local, field_count`. Args opcionais: `post_id` (filtra por contexto), `search`, `active_only` (default true). |
|
||||
| `get-field-group` | read | `check_read_permission` | não | Devolve um field group completo: `location` rules + árvore recursiva de campos (`sub_fields`/`layouts` até profundidade 10). Args: `{ key }` (key ou ID numérico). |
|
||||
| `list-options-pages` | read | `check_read_permission` | não | Lista options pages ACF (feature PRO; vazio em ACF free). Sem args. |
|
||||
| `get-fields` | read | `check_fields_permission` (`edit_post`/`manage_options` conforme alvo) | não | Lê valores de campos de um post ou options page. Args: `{ post_id }` OU `{ options_page }`; opcionais `{ fields: string[] }` (filtra), `{ include_field_objects: bool }` (envolve valor com type/label). |
|
||||
| `list-post-types` | read | `check_manage_permission` (`manage_options`) | **sim** | Lista CPTs geridos por ACF (`acf-post-type` CPT interno). |
|
||||
| `get-post-type` | read | `check_manage_permission` | **sim** | Devolve um CPT ACF completo por `{ key }`. |
|
||||
| `list-taxonomies` | read | `check_manage_permission` | **sim** | Lista taxonomias geridas por ACF. |
|
||||
| `get-taxonomy` | read | `check_manage_permission` | **sim** | Devolve uma taxonomia ACF completa por `{ key }`. |
|
||||
| `update-fields` | write | `check_fields_permission` | não | Escreve valores de campos (incl. linhas repeater/flexible/gallery) num post ou options page. Args: `{ post_id\|options_page, fields: {name: value} }`. Campos de tipo PRO (repeater/flexible_content/gallery/clone) recusados se ACF não for Pro. Linhas de `flexible_content` validadas contra `acf_fc_layout` conhecido. Regista before-image no Change Ledger (`EMCP_Tools_Change_Recorder::record_acf_fields`) se essa classe existir. |
|
||||
| `create-field-group` | write | `check_manage_permission` | não | Cria um field group com campos + location rules via `acf_import_field_group()` (persiste no CPT `acf-field-group`; NÃO usa `acf_add_local_field_group()`, que seria só memória). Args: `{ title, fields: [...], location?: [[...]] }`. |
|
||||
| `update-field-group` | write | `check_manage_permission` | não | Edita um field group **guardado em BD**: título, location, active, position, adiciona novos campos (`add_fields`), altera settings mutáveis de campos existentes (`update_fields`). **Recusa** grupos `local` (registados por acf-json/PHP) e qualquer alteração a `name`/`type` de um campo existente (imutáveis — evitaria orfanar postmeta). Sem delete. |
|
||||
| `create-acf-post-type` | write | `check_manage_permission` | **sim** | Regista um CPT via ACF (dados, sem código) usando `acf_import_post_type()`. Args: `{ post_type, title, singular?, public?, hierarchical?, show_in_rest?, supports?, has_archive?, taxonomies? }`. Slug validado contra 21 slugs reservados do WordPress (`post`, `page`, `attachment`, etc.). |
|
||||
| `update-acf-post-type` | write | `check_manage_permission` | **sim** | Edita um CPT ACF existente por `{ key }`. **Slug imutável** (recusa se `post_type` no input difere do actual — orfanaria conteúdo). |
|
||||
| `create-acf-taxonomy` | write | `check_manage_permission` | **sim** | Regista uma taxonomia via ACF. Args: `{ taxonomy, title, object_type: string[] (post types), singular?, hierarchical?, public?, show_in_rest? }`. |
|
||||
| `update-acf-taxonomy` | write | `check_manage_permission` | **sim** | Edita uma taxonomia ACF existente por `{ key }`. **Slug imutável** (orfanaria termos). |
|
||||
|
||||
`cpt_tax_supported()` = `function_exists('acf_get_acf_post_types') && acf_get_acf_taxonomies && acf_import_post_type && acf_import_taxonomy` (ACF 6.1+). Operações CPT/tax são omitidas do catálogo de descoberta e recusam com `acf_cpt_tax_unsupported` se a versão for anterior.
|
||||
|
||||
### 1.3 Decisões de design não óbvias (ACF)
|
||||
|
||||
- **Escrita sempre por `field_key`, nunca por `name`**: comentário no código — `update_field()` por nome falha silenciosamente em alvos que ainda não têm valor guardado para o campo. O resolvedor de campo (`resolve_field()`) tenta: (1) prefixo `field_` directo via `acf_get_field()`, (2) índice por nome/key construído a partir dos field groups aplicáveis ao alvo, (3) fallback `get_field_object()` para alvos options sem valor guardado ainda.
|
||||
- **`update_field()` não confia no valor de retorno**: devolve `false` também num no-op (valor idêntico), por isso o sucesso é confirmado por uma releitura (`get_field()`) após cada escrita, não pelo booleano.
|
||||
- **Índice de campos por request, invalidado após escrita**: `field_index_cache` (array associativo por `target`) evita relistar field groups a cada operação, mas é explicitamente apagado (`unset`) após `update-fields`/`create-field-group`/`update-field-group` para uma leitura subsequente no mesmo request ver o estado fresco.
|
||||
- **Options pages sem lookup reverso**: ACF não tem forma de mapear um alvo `options` de volta aos seus field groups; o índice cai para (a) todo o field group com QUALQUER regra `location` de tipo `options_page`, mais (b) `get_field_objects()` para capturar campos já guardados mesmo sem regra de localização detectável.
|
||||
- **Normalização de valores**: `WP_Post`/`WP_User`/`WP_Term` viram resumos compactos (`{id, title/name, ...}`); arrays de imagem/ficheiro ACF (`return_format=array`) reduzidos a `{id, url, alt, mime}` em vez do array completo (que inclui `sizes` com dezenas de entradas).
|
||||
- **Sem delete, em lado nenhum**: nem field groups, nem campos, nem valores, nem CPTs/taxonomias — write-only-forward. Confirma o padrão documentado no cabeçalho do ficheiro: "there is deliberately NO delete tool".
|
||||
|
||||
## 2. Meta Box (metabox.io)
|
||||
|
||||
**Ficheiro:** `includes/abilities/class-metabox-abilities.php`. **Classe:**
|
||||
`EMCP_Tools_Meta_Box_Abilities`. **Desde:** 3.4.2.
|
||||
|
||||
**Condição de registo:**
|
||||
```php
|
||||
// Meta Box abilities — only when Meta Box (free or extensions) is active.
|
||||
if ( class_exists( 'EMCP_Tools_Meta_Box_Abilities' ) && EMCP_Tools_Meta_Box_Abilities::metabox_active() ) {
|
||||
$metabox = new EMCP_Tools_Meta_Box_Abilities();
|
||||
$metabox->register();
|
||||
...
|
||||
}
|
||||
```
|
||||
`metabox_active()` = `defined('RWMB_VER') && function_exists('rwmb_get_registry')`.
|
||||
|
||||
### 2.1 Abilities MCP registadas (2)
|
||||
|
||||
| Ability | Input schema | Descrição | `permission_callback` | readonly/destructive |
|
||||
|---|---|---|---|---|
|
||||
| `emcp-tools/metabox-read` | `{ operation?, arguments? }` | Lê field groups Meta Box registados, as suas definições de campo, e valores de campo num post/objecto. | `check_read_permission()` = `edit_posts` | readonly=true, idempotent=true |
|
||||
| `emcp-tools/metabox-write` | `{ operation?, arguments? }` | Escreve valores de campo Meta Box. **Desligado por omissão** (Tools → Plugins → Meta Box). | `check_read_permission()` — mesma nota que ACF: gate real é por operação | readonly=false, destructive=false, idempotent=false |
|
||||
|
||||
### 2.2 As 4 operações internas
|
||||
|
||||
| operação | modo | Descrição |
|
||||
|---|---|---|
|
||||
| `list-field-groups` | read | Lista meta boxes registados: `id, title, object_type, post_types[], field_count`. Args opcionais: `search`, `object_type`. |
|
||||
| `get-field-group` | read | Um meta box completo por `{ id }`: título, object_type, post_types, árvore de campos recursiva (campos `clone`/group aninhados até profundidade 10). |
|
||||
| `get-fields` | read | Lê valores via `rwmb_meta()`. Args: `{ post_id }` OU `{ object_type, object_id }`; opcionais `{ fields: string[], include_field_settings: bool }`. |
|
||||
| `update-fields` | write | Escreve valores via `rwmb_set_meta()` (não devolve nada — confirmação por releitura). Args: `{ post_id\|object_type+object_id, fields: {id: value} }`. |
|
||||
|
||||
Ambas as operações de leitura/escrita têm o mesmo `perm`: `check_read_permission`/`check_fields_permission` — sem distinção read/write op-a-op como no ACF (só há uma operação de escrita).
|
||||
|
||||
### 2.3 Diferenças-chave face ao ACF
|
||||
|
||||
- **Meta Box free core não tem UI de construção de campos** — campos são declarados em PHP via o filtro `rwmb_meta_boxes`. Por isso NÃO HÁ authoring de field groups/CPT/taxonomia aqui (ao contrário do ACF) — só leitura de definições e leitura/escrita de VALORES.
|
||||
- **Nomenclatura invertida face ao ACF**: no Meta Box, o `id` de um campo É a meta key (equivalente ao `name` do ACF), e `name` é o label humano (equivalente ao `label` do ACF) — nota explícita no cabeçalho do ficheiro por ser uma fonte comum de confusão ao portar lógica entre as duas integrações.
|
||||
- **`applicable_fields()`**: para alvos `post`, só considera meta boxes cujo `post_types` inclui o tipo do post concreto (Meta Box permite meta boxes restritos a post types específicos); para outros `object_type`, aceita todas as meta boxes desse tipo sem mais filtragem.
|
||||
- **Normalização de valor**: reconhece o formato específico de imagem/ficheiro do Meta Box — array associativo com `ID`+`url` E (`full_url` OU `path` OU `mime_type`) — distinto da assinatura ACF (`ID`+`url`+`mime_type`).
|
||||
- **`resolve_target()` tem um caso de normalização subtil**: `{ object_type:'post', object_id:N }` sem `post_id` é reescrito internamente para o caminho `post_id` (garante o mesmo 404-check e gate `edit_post` que `{post_id:N}` receberia diretamente).
|
||||
|
||||
## 3. Forms — infra-estrutura genérica (`EMCP_Tools_Form_Integration`)
|
||||
|
||||
**Ficheiro:** `includes/abilities/forms/class-form-integration.php`. **Classe:** classe base
|
||||
abstracta `EMCP_Tools_Form_Integration`. **Desde:** 3.5.0. Directório `forms/` no Free contém
|
||||
apenas ESTE ficheiro + `class-cf7-integration.php` — nenhum outro ficheiro de plugin de
|
||||
formulário existe nesta árvore (confirmado por listagem directa, secção 6).
|
||||
|
||||
### 3.1 Contrato abstracto
|
||||
|
||||
Cada integração concreta implementa:
|
||||
- `id(): string` — id curto, usado para construir os nomes dos tools (`<id>-read`/`<id>-write`).
|
||||
- `label(): string` — label humano.
|
||||
- `is_active(): bool` — se o plugin de formulários alvo está activo.
|
||||
- `operations(): array<string,array>` — mapa `nome => { mode, run, perm, desc, confirm? }`.
|
||||
|
||||
`is_available()` (usado pelo registrar para decidir se regista) = `is_active()` por omissão.
|
||||
|
||||
### 3.2 O que a base fornece a TODAS as integrações de formulários
|
||||
|
||||
- **`register()`** — regista os dois tools `<id>-read`/`<id>-write` com o schema partilhado
|
||||
`{ operation?, arguments? }`. Meta annotations do tool write: `readonly=false, destructive=true,
|
||||
idempotent=false` — nota: **destructive=true por omissão na base**, diferente de ACF/Meta
|
||||
Box/SEO (que marcam `destructive=false`) — reflecte que integrações Pro de formulários
|
||||
provavelmente apagam entradas/submissões, uma operação genuinamente destrutiva que CF7 (sem
|
||||
armazenamento de submissões) nunca exerce.
|
||||
- **`can_read()`** = `edit_posts`; **`can_write()`** = `manage_options` — ambas as gates coarse
|
||||
registadas no MCP; a gate real corre por operação via `$op['perm']`.
|
||||
- **`dispatch()`** — resolve `operation`, verifica `is_active()` (senão `plugin_inactive`, HTTP 409),
|
||||
valida a operação existe no modo certo (`unknown_operation`, HTTP 404), corre `$op['perm']`
|
||||
(`forbidden`, HTTP 403), e — **gate de confirmação genérica**: se `$op['confirm']===true`,
|
||||
exige `arguments.confirm===true` (senão `confirmation_required`, HTTP 400); o campo `confirm`
|
||||
é removido de `$args` antes de chamar o executor real (não polui o input do handler).
|
||||
- **Sem mecanismo de change-ledger automático** — ao contrário da base SEO (secção 4), a base
|
||||
Forms não regista snapshots before-image; qualquer undo teria de ser implementado por
|
||||
integração concreta.
|
||||
|
||||
## 4. Contact Form 7 (integração Free com ficheiro dedicado)
|
||||
|
||||
**Ficheiro:** `includes/abilities/forms/class-cf7-integration.php`. **Classe:**
|
||||
`EMCP_Tools_CF7_Integration extends EMCP_Tools_Form_Integration`. **Desde:** 3.5.0.
|
||||
|
||||
**Condição de registo:**
|
||||
```php
|
||||
if ( class_exists( 'EMCP_Tools_CF7_Integration' ) ) {
|
||||
$form_integrations[] = new EMCP_Tools_CF7_Integration();
|
||||
}
|
||||
... // depois, para todos os $form_integrations:
|
||||
foreach ( $form_integrations as $form_integration ) {
|
||||
if ( $form_integration->is_available() ) {
|
||||
$form_integration->register();
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
`is_active()` = `class_exists('WPCF7_ContactForm') || defined('WPCF7_VERSION')`. Nenhum gate de
|
||||
licença Pro do EMCP — CF7 é a ÚNICA integração de formulários incluída no build Free.
|
||||
|
||||
Verificado contra Contact Form 7 6.1.6 (comentário no cabeçalho do ficheiro lista a superfície
|
||||
API usada: `WPCF7_ContactForm::find()`, `::get_instance()`, `->id()/->name()/->title()`,
|
||||
`->scan_form_tags()`, `->prop()`, `->set_properties()+->save()`).
|
||||
|
||||
### 4.1 Abilities MCP registadas (2, via a base)
|
||||
|
||||
| Ability | Descrição gerada (`label().' Read/Write'`) | `permission_callback` |
|
||||
|---|---|---|
|
||||
| `emcp-tools/cf7-read` | "Contact Form 7, read operations. Call with no operation to list them." | `can_read()` = `edit_posts` |
|
||||
| `emcp-tools/cf7-write` | "Contact Form 7, write operations..." | `can_write()` = `manage_options` |
|
||||
|
||||
### 4.2 As 7 operações internas
|
||||
|
||||
| operação | modo | `perm` (capability CF7 real) | Descrição |
|
||||
|---|---|---|---|
|
||||
| `list-forms` | read | `wpcf7_read_contact_forms` (≈`edit_posts`) | Lista todos os formulários CF7: `id, title, slug, field_count`. |
|
||||
| `get-form` | read | idem | Um formulário completo por `{ form_id }`: `fields[], mail, mail_2, messages, additional_settings`. |
|
||||
| `list-notifications` | read | idem | Os dois templates de email (`mail`, `mail_2`) por `{ form_id }`. |
|
||||
| `get-settings` | read | idem | `messages` + `additional_settings` por `{ form_id }`. |
|
||||
| `update-notification` | write | `wpcf7_edit_contact_forms` (≈`publish_pages`) | Actualiza um template de email por merge (não substitui): `{ form_id, notification: "mail"\|"mail_2", mail: {subject?, sender?, recipient?, body?, additional_headers?, attachments?, use_html?, active?} }`. |
|
||||
| `update-messages` | write | idem | Actualiza mensagens de validação/resposta por merge: `{ form_id, messages: {key: value} }`. |
|
||||
| `update-form-settings` | write | idem | Substitui por completo o bloco Additional Settings: `{ form_id, additional_settings: string }`. |
|
||||
|
||||
**Nota**: os `perm` reais aqui são MAIS finos que a gate coarse da base (`can_write()=manage_options`)
|
||||
— usam as capacidades nativas do CF7 (`wpcf7_edit_contact_forms`), que por omissão mapeiam para
|
||||
`publish_pages`, não `manage_options`. Um editor sem `manage_options` mas com `publish_pages` é
|
||||
assim recusado pela gate coarse do MCP (`can_write`) ANTES sequer de chegar à gate fina do CF7 —
|
||||
uma limitação prática: a capability CF7 nativa nunca é realmente exercida nesta implementação
|
||||
porque a gate MCP é mais restritiva.
|
||||
|
||||
**CF7 não guarda submissões** (sem addon Flamingo/DB próprio) — por isso não há operações de
|
||||
"entries" (ao contrário do que os 8 plugins Pro de formulários presumivelmente expõem, dado
|
||||
todos guardarem submissões nativamente).
|
||||
|
||||
## 5. SEO — infra-estrutura genérica (`EMCP_Tools_SEO_Integration`)
|
||||
|
||||
**Ficheiro:** `includes/abilities/seo/class-seo-integration.php`. **Classe:** base abstracta
|
||||
`EMCP_Tools_SEO_Integration`. **Desde:** 3.5.0. Directório `seo/` no Free contém apenas ESTE
|
||||
ficheiro + `class-slimseo-integration.php`.
|
||||
|
||||
Distinção documentada no cabeçalho: isto é **diferente** do toolkit Pro SEO & Accessibility
|
||||
(`audit-page-seo`/`generate-meta-tags`, ver doc 01/02), que ANALISA e GERA em vez de
|
||||
ler/escrever os dados que um plugin de SEO já guarda.
|
||||
|
||||
### 5.1 Contrato abstracto
|
||||
|
||||
Mesma forma que Forms: `id()`, `label()`, `is_active()`, `operations()`. Diferenças de
|
||||
comportamento da base face a Forms:
|
||||
|
||||
- **`can_read()`** = `edit_posts`; **`can_write()`** = `edit_posts` (não `manage_options` —
|
||||
escrever SEO de um post é uma operação de editor normal, não administrativa).
|
||||
- **Meta annotations do tool write**: `readonly=false, destructive=false, idempotent=false` —
|
||||
ao contrário de Forms, aqui `destructive=false` por omissão (escritas de metadados SEO nunca
|
||||
apagam dados irrecuperáveis por si mesmas).
|
||||
- **Change-ledger automático (feature exclusiva da base SEO, ausente em Forms/ACF/Meta Box)**:
|
||||
para qualquer operação de modo `write` cujos `arguments` incluam `post_id` ou `term_id`, a base
|
||||
chama `recordable_meta_keys($object)` (que cada integração concreta sobrepõe — devolve as meta
|
||||
keys que ela escreve para esse tipo de objecto; vazio por omissão = sem registo automático),
|
||||
tira um `snapshot_meta()` (before-image dessas keys) ANTES de correr o executor, e — se o
|
||||
resultado não for `WP_Error` e `EMCP_Tools_Change_Log` existir — grava uma entrada
|
||||
`{domain:'seo', action:'update', target:'<id>:<object>:<obj_id>', rollback:{type:'meta-before-image', object, id, before}}`.
|
||||
Isto dá rollback automático a TODAS as integrações SEO que declarem as suas meta keys, sem cada
|
||||
uma ter de implementar a lógica de undo.
|
||||
|
||||
### 5.2 Abilities MCP e a mesma gate de confirmação
|
||||
|
||||
A `dispatch()` reutiliza a MESMA forma de Forms: catálogo sem `operation`, `plugin_inactive`
|
||||
(409) se `is_active()` falhar, `unknown_operation` (404), `forbidden` (403) por `$op['perm']`,
|
||||
`confirmation_required` (400) se `$op['confirm']===true` e `arguments.confirm` não for `true`.
|
||||
|
||||
## 6. Slim SEO (integração Free com ficheiro dedicado)
|
||||
|
||||
**Ficheiro:** `includes/abilities/seo/class-slimseo-integration.php`. **Classe:**
|
||||
`EMCP_Tools_SlimSEO_Integration extends EMCP_Tools_SEO_Integration`. **Desde:** 3.5.0.
|
||||
|
||||
**Condição de registo:**
|
||||
```php
|
||||
if ( class_exists( 'EMCP_Tools_SlimSEO_Integration' ) ) {
|
||||
$seo_integrations[] = new EMCP_Tools_SlimSEO_Integration();
|
||||
}
|
||||
... // depois, para todos os $seo_integrations, mesma forma que Forms:
|
||||
foreach ( $seo_integrations as $seo_integration ) {
|
||||
if ( $seo_integration->is_available() ) { $seo_integration->register(); ... }
|
||||
}
|
||||
```
|
||||
`is_active()` = `defined('SLIM_SEO_VER')`. Slim SEO é a ÚNICA integração de SEO incluída no build
|
||||
Free. Armazenamento confirmado ao vivo (comentário "Verified live" no cabeçalho): um único array
|
||||
meta `slim_seo` por post/termo (chaves: `title, description, canonical, noindex, nofollow,
|
||||
facebook_image, twitter_image`), mais uma option `slim_seo` para as definições do site.
|
||||
|
||||
### 6.1 Abilities MCP registadas (2, via a base)
|
||||
|
||||
| Ability | `permission_callback` |
|
||||
|---|---|
|
||||
| `emcp-tools/slimseo-read` | `can_read()` = `edit_posts` |
|
||||
| `emcp-tools/slimseo-write` | `can_write()` = `edit_posts` |
|
||||
|
||||
### 6.2 As 5 operações internas
|
||||
|
||||
| operação | modo | `perm` | Descrição |
|
||||
|---|---|---|---|
|
||||
| `get-post-seo` | read | `edit_posts` | Metadados SEO de um post por `{ post_id }`: `title, description, canonical, noindex, nofollow, og_image, twitter_image` (view unificada — ver mapeamento abaixo). |
|
||||
| `get-term-seo` | read | `edit_posts` | Idem para `{ term_id }`. |
|
||||
| `get-settings` | read | `manage_options` | Definições SEO do site (a option `slim_seo`). |
|
||||
| `update-post-seo` | write | `edit_posts` | Actualiza por merge campo-a-campo: `{ post_id, title?, description?, canonical?, noindex?, nofollow?, og_image?, twitter_image? }`. |
|
||||
| `update-term-seo` | write | `edit_posts` | Idem para `{ term_id, ... }`. |
|
||||
|
||||
`recordable_meta_keys()` sobreposto para devolver `['slim_seo']` (a única meta key, para
|
||||
qualquer `$object`) — activa o registo automático no Change Ledger da base para AMBAS as
|
||||
operações de escrita.
|
||||
|
||||
### 6.3 Camada de mapeamento field↔meta-key
|
||||
|
||||
A classe mantém um `map(): array<string,string>` que traduz o vocabulário unificado do EMCP
|
||||
(`title, description, canonical, noindex, nofollow, og_image, twitter_image`) para as chaves
|
||||
reais do array `slim_seo` do plugin (`title, description, canonical, noindex, nofollow,
|
||||
**facebook_image**, twitter_image` — nota: só `og_image`→`facebook_image` difere). `read_view()`
|
||||
e `apply()` usam este mapa nos dois sentidos; `noindex`/`nofollow` são forçados a booleano
|
||||
(`!empty()`), o resto passa por `(string)` quando escalar.
|
||||
|
||||
## 7. WooCommerce e demais integrações Pro-only — não presentes neste build
|
||||
|
||||
O `class-ability-registrar.php` referencia 18 classes de integração via `class_exists()` que
|
||||
**não existem em nenhum ficheiro desta árvore Free** — confirmado por listagem directa de
|
||||
`includes/abilities/` (topo), `includes/abilities/forms/`, `includes/abilities/seo/`, e
|
||||
`includes/` (topo, todas as subpastas), sem grep recursivo disponível nesta sessão (as
|
||||
ferramentas `glob`/`grep` não recursam paths `ssh://`; a confirmação foi feita listando cada
|
||||
directório candidato e comparando os nomes de ficheiro presentes contra os nomes de classe
|
||||
referenciados). Nenhum destes ficheiros existe nesta instalação — não há schemas nem
|
||||
comportamento a documentar; só a metadata de gating, copiada verbatim do registrar.
|
||||
|
||||
| Classe (referência no registrar) | Plugin de terceiros integrado | Condição de gating exacta (copiada do registrar) |
|
||||
|---|---|---|
|
||||
| `EMCP_Tools_Woo_Integration` | WooCommerce | `class_exists( 'EMCP_Tools_Woo_Integration' ) && EMCP_Tools_Woo_Integration::woo_active()` — comentário no código: "WooCommerce abilities (Pro) — only when WooCommerce is active." |
|
||||
| `EMCP_Tools_WPForms_Integration` | WPForms | `function_exists('emcp_tools_fs') && emcp_tools_fs()->can_use_premium_code()` (licença Pro) **E** `class_exists($classe)` **E** depois `$integration->is_available()` (= `is_active()`, plugin alvo activo) — comentário: "CF7 is free; the five [sic, na prática oito] entry-storing plugins are Pro. Each registers only when its plugin is active." |
|
||||
| `EMCP_Tools_GravityForms_Integration` | Gravity Forms | idem (mesmo loop, mesma tripla gate: licença Pro + ficheiro presente + plugin activo) |
|
||||
| `EMCP_Tools_FluentForms_Integration` | Fluent Forms | idem |
|
||||
| `EMCP_Tools_NinjaForms_Integration` | Ninja Forms | idem |
|
||||
| `EMCP_Tools_Formidable_Integration` | Formidable Forms | idem |
|
||||
| `EMCP_Tools_MetForm_Integration` | MetForm | idem |
|
||||
| `EMCP_Tools_SureForms_Integration` | SureForms | idem |
|
||||
| `EMCP_Tools_Forminator_Integration` | Forminator | idem |
|
||||
| `EMCP_Tools_Yoast_Integration` | Yoast SEO | `function_exists('emcp_tools_fs') && emcp_tools_fs()->can_use_premium_code()` **E** `class_exists($classe)` **E** `$integration->is_available()` — comentário: "Slim SEO is free; the other 6 are Pro. Each registers only when its SEO plugin is active." |
|
||||
| `EMCP_Tools_RankMath_Integration` | Rank Math | idem |
|
||||
| `EMCP_Tools_AIOSEO_Integration` | All in One SEO | idem |
|
||||
| `EMCP_Tools_SeoPress_Integration` | SEOPress | idem |
|
||||
| `EMCP_Tools_SEOFramework_Integration` | The SEO Framework | idem |
|
||||
| `EMCP_Tools_SureRank_Integration` | SureRank | idem |
|
||||
| `EMCP_Tools_EssentialAddons_Integration` | Essential Addons for Elementor | Só `class_exists( 'EMCP_Tools_EssentialAddons_Integration' )` **E** `$addon_pack->is_available()` — **sem** o gate explícito `emcp_tools_fs()->can_use_premium_code()` no laço (ao contrário de Forms/SEO); comentário: "Elementor addon widget packs (Pro). Each pack contributes ONE read tool for discovery + curation; widgets are placed with the generic add-free-widget tool, so there is deliberately no write tool here." O ficheiro simplesmente não existe fora do build Pro, logo `class_exists()` é sempre falso no Free independentemente de licença. |
|
||||
| `EMCP_Tools_PremiumAddons_Integration` | Premium Addons for Elementor | idem (mesmo laço, mesma ausência de gate de licença explícito no registrar) |
|
||||
| `EMCP_Tools_UAE_Integration` | Ultimate Addons for Elementor (anteriormente Header Footer Elementor) | `class_exists( 'EMCP_Tools_UAE_Integration' )` **E** `$emcp_uae->is_available()` — comentário: "Ultimate Addons for Elementor (Pro)... Both a widget pack AND a data plugin, so unlike the pure packs it keeps the house read/write dispatcher pair: discovery + templates on read, templates on write." |
|
||||
|
||||
**Nota adicional confirmada pelo registrar** (fora do escopo directo desta tarefa mas
|
||||
adjacente): outras integrações de "Themes-tab" também Pro-only e ausentes desta árvore —
|
||||
`EMCP_Tools_GeneratePress_Integration`, `EMCP_Tools_GenerateBlocks_Integration`,
|
||||
`EMCP_Tools_Blocksy_Blocks_Integration`, `EMCP_Tools_Blocksy_Extensions_Integration` — todas só
|
||||
`class_exists()` + `is_available()`, sem gate de licença explícito no laço (mesmo padrão de
|
||||
EssentialAddons/PremiumAddons/UAE). Não fazem parte do escopo pedido (ACF/MetaBox/Forms/SEO/Woo)
|
||||
mas são citadas aqui por confirmarem o padrão: **todas** as integrações Pro deste plugin usam
|
||||
`class_exists()` simples como gate primário — a distinção Free/Pro é feita por PRESENÇA DE
|
||||
FICHEIRO (a pasta `emcp-pro/` inclui-os, `emcp-tools/` não), não por uma verificação de licença
|
||||
em runtime dentro de cada laço de registo individual (excepto forms/SEO, que adicionam
|
||||
explicitamente `emcp_tools_fs()->can_use_premium_code()` como segunda camada — provavelmente
|
||||
porque esses 14 ficheiros de facto existem no zip Pro mas o acesso à FUNCIONALIDADE ainda
|
||||
depende de licença activa, ao contrário dos addon-packs/UAE, que talvez sejam gratuitos dentro do
|
||||
próprio Pro sem sub-gate de licença adicional).
|
||||
|
||||
## 8. Blueprint para réplica
|
||||
|
||||
**Copiar quase 1:1:**
|
||||
- **O padrão de dispatcher de dois braços** (`<domínio>-read`/`<domínio>-write`, catálogo sem
|
||||
`operation`, despacho por `operation`+`arguments`) — é a peça que mantém o catálogo total de
|
||||
tools pequeno com dezenas de integrações possíveis. Vale a pena ter DUAS variantes conforme a
|
||||
complexidade: dispatcher "à mão" para integrações com estado rico e reaproveitamento pesado
|
||||
(ACF, Meta Box — cada uma tem a sua própria noção de "target"/"field index"), e uma classe base
|
||||
abstracta partilhada para famílias homogéneas de integrações simples-mas-numerosas (Forms, SEO)
|
||||
onde cada concreta só define `operations()` + os `run` callables.
|
||||
- **A gate de confirmação genérica da base** (`confirm: true` obrigatório em `arguments` para
|
||||
operações marcadas `confirm=>true`) — mecanismo barato e reutilizável para qualquer operação
|
||||
irreversível futura (delete de entrada de formulário, delete de redirect, etc.), sem cada
|
||||
integração ter de reimplementar a checagem.
|
||||
- **O change-ledger automático da base SEO** (`recordable_meta_keys()` + snapshot before-image +
|
||||
`EMCP_Tools_Change_Log::record()`) é o exemplo mais elegante deste documento: dá rollback
|
||||
automático a qualquer integração que declare as suas meta keys, com ZERO código extra por
|
||||
integração concreta. **Recomendação forte**: subir este padrão para a base Forms também (hoje
|
||||
ausente lá) e generalizar para ACF/Meta Box (hoje cada uma implementa o seu próprio snapshot
|
||||
ad-hoc — ACF via `EMCP_Tools_Change_Recorder::record_acf_fields`, Meta Box sem qualquer
|
||||
registo). Um único mecanismo de "snapshot de meta keys antes de escrever" partilhado por TODAS
|
||||
as integrações de terceiros pouparia manutenção e cobriria o gap do Meta Box.
|
||||
- **Escrita sempre por identificador estável** (ACF: sempre por `field_key`, nunca por `name`) —
|
||||
é uma lição de bug real ("silently fails on targets that have no stored value yet"), replicar
|
||||
esta disciplina em qualquer integração própria que tenha um par nome-humano/chave-estável.
|
||||
- **Imutabilidade de slug/tipo em updates** (ACF post-type/taxonomy slug; CF7 sem rename de
|
||||
formulário) — recusar explicitamente mudanças que orfanariam dados existentes é barato e evita
|
||||
uma classe inteira de bugs de integridade referencial silenciosa.
|
||||
|
||||
**Simplificar:**
|
||||
- **O gate duplo de licença Pro em Forms/SEO** (`can_use_premium_code()` + `class_exists()` +
|
||||
`is_available()`) é redundante para uma réplica que não tem modelo de negócio Free/Pro — uma
|
||||
reescrita própria só precisa da terceira camada (`is_active()`/plugin alvo instalado).
|
||||
- **A normalização de valores por tipo de objecto WP** (`WP_Post`/`WP_User`/`WP_Term` →
|
||||
resumos compactos) está duplicada quase verbatim entre ACF e Meta Box (`normalize_value()`
|
||||
em cada classe, com pequenas variações de forma de imagem/ficheiro). Vale a pena extrair um
|
||||
helper único partilhado (`EMCP_Tools_Value_Normalizer::normalize($value)`) parametrizável por
|
||||
"assinatura de imagem/ficheiro" (ACF: `ID+url+mime_type`; Meta Box: `ID+url` + uma de
|
||||
`full_url`/`path`/`mime_type`) em vez de reimplementar a árvore recursiva duas vezes.
|
||||
|
||||
**Deixar de fora (ou adiar bastante):**
|
||||
- **CPT/taxonomia "managed by ACF"** (ACF 6.1+) é uma feature de nicho (a maioria dos sites
|
||||
regista CPTs por código, não pela UI do ACF) — só vale a pena replicar se o público-alvo da
|
||||
réplica usar isto activamente. É também a parte mais frágil (7 das 15 operações ACF, gate por
|
||||
versão, slugs reservados) — maior superfície de manutenção por unidade de valor entregue.
|
||||
- **Options pages ACF** — feature exclusivamente Pro do plugin de terceiros; sem ACF Pro
|
||||
instalado esta parte do código é sempre um array vazio. Só vale a pena manter o "stub" que
|
||||
devolve `{pro: false, pages: []}` graciosamente, não investir em lógica adicional.
|
||||
|
||||
**Risco/gotcha não óbvio encontrado no código (citação directa):**
|
||||
- `class-ability-registrar.php`, comentário no `try/catch` de `register_all()`: "Ability
|
||||
registration runs on every admin page load and every REST request, so an exception here is a
|
||||
site-wide fatal: wp-admin becomes unreachable... (issue #100, where a host malware scanner had
|
||||
quarantined one of our class files, leaving `require_once` satisfied but the class undeclared).
|
||||
No single tool group is worth locking an admin out of their own site." — **implicação directa
|
||||
para as 5 integrações deste documento**: cada bloco de registo (ACF, Meta Box, cada form/SEO
|
||||
integration) corre dentro deste `try/catch` amplo a nível de `register_groups()`; um erro fatal
|
||||
ao construir QUALQUER uma destas classes (ex. uma versão incompatível de ACF que remova uma
|
||||
função que o código assume existir) não derruba o site — simplesmente essa família de tools
|
||||
fica ausente do catálogo, silenciosamente, com log via `error_log`. Uma réplica DEVE envolver o
|
||||
registo de cada grupo de integração de terceiros no mesmo tipo de guarda — nunca deixar uma
|
||||
dependência externa instável poder tirar o wp-admin do ar.
|
||||
- ACF `execute_update_fields()`: comentário explícito sobre a armadilha de escrever por `name`
|
||||
em vez de `key` — já citado acima, mas vale reforçar como o exemplo mais concreto de "testámos
|
||||
em produção e partiu" que este documento encontrou.
|
||||
- Meta Box `resolve_target()`: a normalização silenciosa de `{object_type:'post', object_id:N}`
|
||||
para o caminho `post_id` é subtil — sem ela, alguém podia contornar o `edit_post` gate passando
|
||||
o post por `object_type`+`object_id` em vez de `post_id` directo (dois caminhos de input para o
|
||||
mesmo alvo, só um gated correctamente, se não fosse esta normalização).
|
||||
|
||||
## Fonte
|
||||
|
||||
Leitura directa (19-08-2026) de:
|
||||
`includes/abilities/class-acf-abilities.php` (1627 linhas, completo),
|
||||
`includes/abilities/class-metabox-abilities.php` (completo),
|
||||
`includes/abilities/forms/class-form-integration.php` (completo),
|
||||
`includes/abilities/forms/class-cf7-integration.php` (completo),
|
||||
`includes/abilities/seo/class-seo-integration.php` (completo),
|
||||
`includes/abilities/seo/class-slimseo-integration.php` (completo),
|
||||
`includes/abilities/class-ability-registrar.php` (completo, 480+ linhas — todo o `register_groups()`).
|
||||
Listagem directa (não grep recursivo, indisponível para paths `ssh://` nesta sessão) dos
|
||||
directórios `includes/abilities/`, `includes/abilities/forms/`, `includes/abilities/seo/` e
|
||||
`includes/` (topo, todas as subpastas) para confirmar a AUSÊNCIA física de qualquer ficheiro das
|
||||
18 classes Pro-only listadas na secção 7. Cruzado com `docs/00-ARQUITECTURA.md` (mesma sessão de
|
||||
documentação) para o contrato de `emcp_tools_register_ability()` e o padrão de dispatcher de
|
||||
compact tool mode ao nível do servidor.
|
||||
@@ -0,0 +1,654 @@
|
||||
# 09 — Stock Images, EMCP Cloud e Servidor OAuth
|
||||
|
||||
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
|
||||
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
|
||||
19-08-2026. Cobre 3 subsistemas relacionados mas independentes entre si: (1) busca e
|
||||
sideload de imagens de stock, (2) sincronização com o serviço SaaS "EMCP Cloud"
|
||||
(`emcptools.com`), e (3) um servidor de autorização OAuth 2.1 completo, próprio do plugin,
|
||||
que serve de mecanismo de autenticação alternativo ao Application Password para clientes
|
||||
MCP remotos. Este terceiro subsistema **não é exclusivo do Cloud** — é infra-estrutura MCP
|
||||
genérica, e o Cloud é apenas um dos consumidores (via `EMCP_Tools_Gateway_Credential`, ver
|
||||
§2.2.6).
|
||||
|
||||
---
|
||||
|
||||
## 1. Stock Images — busca e sideload de imagens (Unsplash / Pexels / Pixabay)
|
||||
|
||||
### 1.1 `EMCP_Tools_Stock_Image_Abilities` — `includes/abilities/class-stock-image-abilities.php`
|
||||
|
||||
**Condição de registo** (excerto exacto de `class-ability-registrar.php`) — as 2 tools de
|
||||
provider registam-se sempre (não dependem do Elementor); a 3ª só quando o Elementor está
|
||||
activo:
|
||||
|
||||
```php
|
||||
// Stock-image provider tools (search-images + sideload-image) — pure WP core
|
||||
// (a stock-provider search + a Media Library sideload), no Elementor needed,
|
||||
// so they register on any site. add-stock-image (adds a widget) is gated below.
|
||||
$stock_images = new EMCP_Tools_Stock_Image_Abilities( $this->data, $this->factory );
|
||||
$stock_images->register_provider_tools();
|
||||
$this->ability_names = array_merge( $this->ability_names, $stock_images->provider_tool_names() );
|
||||
|
||||
// ---- Elementor-dependent groups: only when Elementor is active ----
|
||||
if ( $elementor_active ) {
|
||||
// ... (outros grupos) ...
|
||||
// Stock images: the add-stock-image widget tool (provider search + sideload
|
||||
// registered unconditionally above); this one adds an image widget so it
|
||||
// needs Elementor.
|
||||
$stock_images->register_widget_tool();
|
||||
$this->ability_names[] = 'emcp-tools/add-stock-image';
|
||||
}
|
||||
```
|
||||
|
||||
| Ability | `input_schema` (resumido) | O que faz | `permission_callback` | Anotações |
|
||||
|---|---|---|---|---|
|
||||
| `search-images` | `query` (str, obrig.), `provider` (enum `unsplash`\|`pexels`\|`pixabay`, opcional — omitido usa o 1º provider com chave configurada), `page` (int), `page_size` (int), `aspect_ratio` (enum `tall`\|`wide`\|`square`) | Pesquisa um provider de stock e devolve resultados normalizados: `{provider, result_count, page, page_count, results:[{id,title,url,thumbnail,width,height,creator,creator_url,license,license_url,attribution,source,foreign_landing_url}]}`. Delega em `EMCP_Tools_Stock_Image_Providers::resolve()` para escolher o cliente, depois num `run_search()` privado partilhado com `add-stock-image` que mapeia o resultado do provider para a forma estável do output_schema. | `check_read_permission()` → `current_user_can('edit_posts')` | `readonly:true, destructive:false, idempotent:true` |
|
||||
| `sideload-image` | `url` (str, obrig. — deve ser o URL **exacto** devolvido por `search-images`, nunca construído/editado à mão), `title`, `alt_text`, `caption`, `attribution`, `convert_webp` (bool) | Descarrega um URL externo para a Media Library via `EMCP_Tools_Url_Guard::safe_download()` (SSRF-guarded — ver §4.2) e devolve `{attachment_id, url, title}`. Antes de descarregar, se o URL for o endpoint de tracking do Unsplash (`api.unsplash.com/.../download`), resolve-o automaticamente para o URL de imagem real via `EMCP_Tools_Unsplash_Client::resolve_download()` — ver gotcha nº1 no blueprint. Em caso de falha, a mensagem de erro inclui uma sugestão accionável específica (URL da API do Unsplash / 404 / 401-403) para corrigir o comportamento de um agente que insista no mesmo URL errado. `convert_webp:false` desactiva a compressão/WebP do módulo Image Optimization só para este upload (via filtro `emcp_tools_optimize_attachment`). | `check_upload_permission()` → `current_user_can('upload_files')` | `readonly:false, destructive:false, idempotent:false` |
|
||||
| `add-stock-image` | `post_id` (int, obrig.), `parent_id` (str, obrig. — ID do container Elementor), `query` (str, obrig.), `provider`, `index` (int, 0=melhor resultado), `position` (int, -1=append), `image_size` (enum), `align` (enum `left`\|`center`\|`right`), `caption`, `aspect_ratio` (default `wide`), `alt_text`, `link_to` (enum `none`\|`file`\|`custom`), `convert_webp` | Compõe `search-images` → escolhe o resultado no `index` → dispara `trigger_download()` do provider (guideline Unsplash) → `sideload-image` → cria um widget `image` Elementor e insere-o em `parent_id` via `EMCP_Tools_Data::insert_element()` + `save_page_data()`. Devolve `{attachment_id, image_url, element_id, original_url, attribution, provider}`. Por omissão filtra por `aspect_ratio=wide` (paisagem) — melhor compatibilidade de layout. | `check_combined_permission()` → `edit_posts` **e** `upload_files` **e** (se `post_id` presente) `edit_post($post_id)` | `readonly:false, destructive:false, idempotent:false` |
|
||||
|
||||
### 1.2 `EMCP_Tools_Stock_Image_Providers` — `includes/class-stock-image-providers.php`
|
||||
|
||||
Registry + resolver estático, sem estado próprio. `map()` define os 3 providers e a
|
||||
**ordem de prioridade de fallback** (`unsplash` → `pexels` → `pixabay`) quando nenhum é
|
||||
pedido explicitamente. Cada entrada do mapa só tem `label` + nome da classe cliente.
|
||||
|
||||
- `has_key($id)` → delega em `{Client}::has_key()` estático.
|
||||
- `available()` → lista de ids com chave configurada, pela ordem do mapa.
|
||||
- `resolve($requested = '')` → devolve `[id, client]` ou `WP_Error`:
|
||||
- se `$requested` não vazio: valida que existe no mapa e que tem chave, senão erro
|
||||
`unknown_provider` / `no_api_key` (mensagem inclui o link para obter chave grátis).
|
||||
- se vazio: usa o primeiro de `available()`, ou `no_api_key` se nenhum estiver configurado.
|
||||
|
||||
Este é o único ponto de acoplamento entre as 3 tools e os 3 clientes — trocar/adicionar um
|
||||
provider é adicionar uma entrada ao `map()`.
|
||||
|
||||
### 1.3 Clientes HTTP dos 3 providers — `includes/class-{unsplash,pexels,pixabay}-client.php`
|
||||
|
||||
Todos seguem o mesmo contrato implícito (duck-typed, sem interface PHP formal):
|
||||
`const OPTION` (nome da option em `wp_options`), `access_key()` estático (constante PHP
|
||||
vence, senão a option — sempre decifrada via `EMCP_Tools_Secret::decrypt_if_needed()`,
|
||||
ver §4.1), `has_key()`, instância `search_images(array $params)`, instância
|
||||
`trigger_download(string $download_location)`.
|
||||
|
||||
| Cliente | Endpoint base | Auth header | Opção wp_options / constante PHP | `orientation`/aspect_ratio mapping | Particularidades |
|
||||
|---|---|---|---|---|---|
|
||||
| `EMCP_Tools_Unsplash_Client` | `https://api.unsplash.com` | `Authorization: Client-ID <key>` | `emcp_tools_unsplash_access_key` / `EMCP_TOOLS_UNSPLASH_ACCESS_KEY` | `wide→landscape`, `tall→portrait`, `square→squarish` | Único com `trigger_download()` REAL (guideline obrigatória da API Unsplash: disparar o `links.download_location` do resultado escolhido). `resolve_download()` resolve o endpoint de tracking `api.unsplash.com/photos/<id>/download` para o URL de imagem real (ver gotcha §Blueprint). `url` devolvido é `urls.regular` (~1080px), não `full`/`raw`. Attribution obrigatória: `creator_url` recebe sempre `?utm_source=emcp_tools&utm_medium=referral` (guideline Unsplash). 401→`invalid_key`; 403→`rate_limited` ("demo apps allow 50 requests/hour"). |
|
||||
| `EMCP_Tools_Pexels_Client` | `https://api.pexels.com/v1` | `Authorization: <key>` (SEM prefixo `Bearer`) | `emcp_tools_pexels_api_key` / `EMCP_TOOLS_PEXELS_API_KEY` | `wide→landscape`, `tall→portrait`, `square→square` | `trigger_download()` é no-op (Pexels não tem endpoint de tracking). `url` = `src.large2x` (fallback `large`/`original`). 401/403→`invalid_key`; 429→`rate_limited` ("200 requests/hour on the free tier"). |
|
||||
| `EMCP_Tools_Pixabay_Client` | `https://pixabay.com/api/` | **chave na query string** (`key=`), não em header | `emcp_tools_pixabay_api_key` / `EMCP_TOOLS_PIXABAY_API_KEY` | `wide→horizontal`, `tall→vertical` (sem `square` — Pixabay não tem essa orientação) | `per_page` restrito a 3-200 (min 3, diferente dos outros 2). Fixa sempre `image_type=photo&safesearch=true`. `trigger_download()` no-op; nota no cabeçalho do ficheiro: "Pixabay's terms require downloading/caching images rather than hotlinking — the sideload step satisfies that." 400/401→`invalid_key`; 429→`rate_limited` ("100 requests/minute"). |
|
||||
|
||||
Todos: `TIMEOUT=15`, `user-agent: Elementor-MCP/<versão> (WordPress/<versão>)`, resposta
|
||||
normalizada para a mesma forma de campo (`normalize_photo()` privado por classe) antes de
|
||||
chegar às abilities — as abilities nunca lidam com a forma nativa de nenhuma API.
|
||||
|
||||
---
|
||||
|
||||
## 2. EMCP Cloud — sincronização remota com `emcptools.com`
|
||||
|
||||
### 2.1 `EMCP_Tools_Cloud_Abilities` — `includes/abilities/class-cloud-abilities.php`
|
||||
|
||||
Cabeçalho do próprio ficheiro: *"Free tree. Registered only when the site is connected to
|
||||
EMCP Cloud (the Cloud module is active and a token bundle is stored)."* — ou seja, ao
|
||||
contrário da maioria dos outros grupos Pro-only deste plugin, **isto é uma feature Free**,
|
||||
só gated pelo estado de ligação, não por licença.
|
||||
|
||||
**Condição de registo** (excerto exacto):
|
||||
|
||||
```php
|
||||
// EMCP Cloud sync tools — only when the site is connected to a cloud account.
|
||||
if ( class_exists( 'EMCP_Tools_Cloud_Abilities' ) && class_exists( 'EMCP_Tools_Cloud_Module' )
|
||||
&& EMCP_Tools_Cloud_Module::is_enabled() && EMCP_Tools_Cloud::is_connected() ) {
|
||||
$cloud_sync = new EMCP_Tools_Cloud_Abilities();
|
||||
$cloud_sync->register();
|
||||
$this->ability_names = array_merge( $this->ability_names, $cloud_sync->get_ability_names() );
|
||||
}
|
||||
```
|
||||
|
||||
`EMCP_Tools_Cloud_Module::is_enabled()` lê `emcp_tools_active_modules` (módulo `cloud`,
|
||||
`default_active()=true` — activo por omissão em todos os sites). `EMCP_Tools_Cloud::is_connected()`
|
||||
exige um token bundle guardado (ver §2.2.1) — nos 3 sites do ecossistema Descomplicar
|
||||
verificados em `skill://emcp-tools`, nenhum está ligado a uma conta Cloud, logo estas 7
|
||||
tools nunca aparecem na prática nesse ambiente.
|
||||
|
||||
| Ability | `input_schema` (resumido) | O que faz | Anotações |
|
||||
|---|---|---|---|
|
||||
| `cloud-status` | `{}` | `GET /api/cloud/v1/me` — plano, limites e uso da conta ligada. | `readonly:true, destructive:false` |
|
||||
| `cloud-backup` | `kind` (enum `block`\|`widget`\|`snippet`, obrig.), `id` (int, obrig.) | Serializa um artefacto de sandbox local (ver doc 06) num "bundle" com checksum e faz `PUT /api/cloud/v1/artifacts`. | `readonly:false, destructive:false, idempotent:true` |
|
||||
| `cloud-list` | `kind` (opcional) | `GET /api/cloud/v1/artifacts[?kind=]` — lista artefactos já guardados na conta. | `readonly:true, destructive:false` |
|
||||
| `cloud-pull` | `artifact_uuid` (str, obrig.), `kind` (opcional) | `GET /api/cloud/v1/artifacts/{uuid}`, decodifica o bundle e importa-o como **novo draft local inactivo** (nunca substitui um artefacto existente). | `readonly:false, destructive:false` |
|
||||
| `cloud-config-sync` | `type` (enum `settings`\|`brand_kit`\|`tool_toggles`, obrig.), `direction` (enum `push`\|`pull`, obrig.), `data` (objecto, só para `push`) | Push/pull de um blob de configuração arbitrário via `/api/cloud/v1/config/{type}`. Usado internamente também por `EMCP_Tools_Settings_Sync` (§2.2.7) com `type=settings`. | `readonly:false, destructive:false` |
|
||||
| `cloud-marketplace-list` | `category` (opcional) | `GET /api/cloud/v1/marketplace[?category=]` — navega listings publicados (públicos, não exige ligação embora a tool em si exija `manage_options`). | `readonly:true, destructive:false` |
|
||||
| `cloud-marketplace-install` | `slug` (str, obrig.) | `POST /api/cloud/v1/marketplace/{slug}/install`, decodifica o bundle devolvido e importa como novo draft local. | `readonly:false, destructive:false` |
|
||||
|
||||
Todas as 7 exigem `current_user_can('manage_options')`. Todas passam pelo padrão comum
|
||||
`execute_*($input) → EMCP_Tools_Cloud_Sync::<método>() → is_wp_error() ? $r : (array) $r`.
|
||||
|
||||
### 2.2 Serviços de suporte (`includes/cloud/`)
|
||||
|
||||
#### 2.2.1 `EMCP_Tools_Cloud` — `class-cloud.php` (config + storage estático, sem rede)
|
||||
|
||||
- `base_url()` — constante `EMCP_TOOLS_CLOUD_URL` > option `emcp_tools_cloud_base_url` >
|
||||
default `https://emcptools.com`; filtrável (`emcp_tools_cloud_base_url`) para
|
||||
staging/self-host.
|
||||
- `site_uuid()` — UUID v4 estável por site, `wp_generate_uuid4()`, mintado lazy no
|
||||
primeiro `get_option()` e persistido em `emcp_tools_site_uuid`.
|
||||
- `save_connection(array $bundle)` / `get_connection()` — o bundle `{access_token,
|
||||
refresh_token, access_expires_at, client_id, connected_at}` é serializado em JSON e
|
||||
guardado **cifrado** (`EMCP_Tools_Secret::encrypt()`, §4.1) na option
|
||||
`emcp_tools_cloud_connection`.
|
||||
- `is_connected()` — `!empty(access_token) || !empty(refresh_token)`.
|
||||
- `SCOPES = 'openid cloud offline_access'` — pedidos ao IdP da Cloud (não confundir com o
|
||||
`SCOPE='mcp'` do servidor OAuth deste plugin, §3 — são dois sistemas OAuth distintos: um
|
||||
em que este site é **cliente** do IdP da Cloud, outro em que este site **é** o servidor).
|
||||
|
||||
#### 2.2.2 `EMCP_Tools_Cloud_Connect` — `class-cloud-connect.php` (cliente OAuth do site contra a Cloud como IdP)
|
||||
|
||||
Este site actua como **cliente PKCE público** contra o servidor OAuth de `emcptools.com`
|
||||
(o mesmo padrão — DCR → authorize → PKCE S256 → token — usado por `EMCP_Tools_OAuth_*`
|
||||
quando o papel é o inverso, ver §3). Sequência completa:
|
||||
|
||||
1. `register_client()` — DCR: `POST {cloud}/api/auth/oauth2/register` com
|
||||
`redirect_uris=[redirect_uri()]` (= `admin-post.php?action=emcp_tools_cloud_callback`),
|
||||
`token_endpoint_auth_method=none`, `client_name=bloginfo('name')`.
|
||||
2. `authorize_url($client_id, $verifier, $csrf)` — constrói o URL de autorização com
|
||||
`code_challenge` S256 derivado do `$verifier`, e `state` = base64url de um JSON
|
||||
`{site_uuid, name, csrf}` (o `$csrf` embutido no state protege contra CSRF sem precisar
|
||||
de um cookie de sessão — porque o browser navega para outro domínio e volta).
|
||||
3. `handle_connect()` (admin-post, nonce-protegido) — faz DCR, gera verifier+csrf, guarda
|
||||
num **transient de 600s** (`emcp_tools_cloud_pending`), e redireciona o browser. Como
|
||||
`wp_safe_redirect()` bloqueia hosts externos por omissão, adiciona o host da Cloud a
|
||||
`allowed_redirect_hosts` só para este redirect deliberado.
|
||||
4. `handle_callback()` (admin-post) — valida o `state` devolvido em **tempo constante**
|
||||
(`EMCP_Tools_OAuth_Util::secure_equals`) contra o CSRF guardado, troca o `code` por
|
||||
tokens (`exchange_code()`), e — se o utilizador marcou o opt-in de gateway no formulário
|
||||
— provisiona automaticamente uma `EMCP_Tools_Gateway_Credential` (§2.2.6), best-effort
|
||||
(uma falha aqui nunca transforma a ligação Cloud, já bem-sucedida, num erro visível).
|
||||
5. `refresh()` — ver bloco dedicado abaixo, é a peça mais elaborada do ficheiro.
|
||||
6. `handle_disconnect()` — desprovisiona o gateway, revoga remotamente
|
||||
(`revoke_remote()`), limpa a ligação local.
|
||||
|
||||
**`refresh()` — mitigação de corrida em rotação de refresh token.** Comentário extenso no
|
||||
próprio código explica o problema: o IdP da Cloud (Better Auth) **rota** o refresh token a
|
||||
cada uso — cada sucesso emite um novo refresh token e invalida o anterior. Duas requests
|
||||
WordPress concorrentes (segundo separador de admin, um heartbeat, uma chamada MCP) que
|
||||
ambas vejam o access token expirado apresentariam o MESMO refresh token; a primeira ganha e
|
||||
rota-o, a segunda é rejeitada com `invalid_grant` — e ingenuamente marcaria a ligação como
|
||||
"unhealthy", sobrescrevendo o bundle recém-rodado com o token morto (bug real que se
|
||||
manifestaria como "Reconnect needed" espúrio). Mitigação em 4 camadas:
|
||||
(1) mutex best-effort via `SELECT GET_LOCK()` do MySQL (`db_lock`/`db_unlock`, degradação
|
||||
graciosa para no-op se `$wpdb` ausente — testes unitários); (2) double-checked locking —
|
||||
volta a ler o bundle depois de obter o lock e sai cedo se outra request já refrescou;
|
||||
(3) uma rejeição de auth que coincide com uma rotação concorrente (refresh_token guardado
|
||||
mudou, ou o access token voltou a estar fresco) é tratada como **sucesso**, nunca
|
||||
sobrescreve o bundle bom; (4) falhas de rede/5xx são tratadas como transitórias e NUNCA
|
||||
marcam a ligação como unhealthy.
|
||||
|
||||
#### 2.2.3 `EMCP_Tools_Cloud_Http` — `class-cloud-http.php` (transporte fino)
|
||||
|
||||
`post_json()`, `post_form()`, `request($method,...)` — todos delegam num `send()` privado
|
||||
que usa `wp_remote_post`/`wp_remote_request` (timeout=20, sslverify=true). Tem um **seam de
|
||||
teste injectável** (`set_transport(callable)`) que permite mockar toda a rede em testes
|
||||
unitários sem tocar em `wp_remote_*`.
|
||||
|
||||
#### 2.2.4 `EMCP_Tools_Cloud_Sync` — `class-cloud-sync.php` (camada aplicacional)
|
||||
|
||||
Traduz operações de negócio para chamadas ao `Cloud_Client` autenticado (§2.2.5):
|
||||
`status()`, `list_remote()`, `backup()`, `pull()`, `push_config()`, `pull_config()`,
|
||||
`marketplace_list()`, `marketplace_install()`, `marketplace_publish()`,
|
||||
`marketplace_state()`, `push_update()`. Note-se `abilities(): EMCP_Tools_Sandbox_Cloud_Abilities`
|
||||
— um nome **confuso por semelhança**: esta classe (documentada em detalhe no doc 06, não
|
||||
aqui) não é uma ability MCP, é o helper local de serialização de bundle (`to_bundle()`,
|
||||
`apply_bundle()`, `uuid()`) partilhado entre as tools locais `export-sandbox-artifact`/
|
||||
`import-sandbox-artifact` (sem rede, doc 06) E as tools de rede `cloud-backup`/`cloud-pull`
|
||||
(este doc) — o mesmo formato de bundle serve os dois casos de uso.
|
||||
|
||||
`bulk_backup(array $kinds = [])` — o contraparte em massa de `backup()`; itera todos os
|
||||
posts das 3 CPTs de sandbox (`kind_post_types()`: snippet/widget/block →
|
||||
`emcp_php_snippet`/`emcp_widget`/`emcp_block`) e chama `backup()` um a um. **Não está
|
||||
exposta como MCP tool** (não há `cloud-bulk-backup` em `Cloud_Abilities`) — só é usada pela
|
||||
UI de admin.
|
||||
|
||||
`marketplace_publish()`/`marketplace_state()`/`push_update()` também não estão expostas
|
||||
como MCP tools — só `marketplace_list`/`marketplace_install` o estão; o resto é
|
||||
funcionalidade de admin (submissão de listings ao marketplace).
|
||||
|
||||
#### 2.2.5 `EMCP_Tools_Cloud_Client` — `class-cloud-client.php` (REST autenticado)
|
||||
|
||||
`valid_access_token()` — devolve o access token guardado, refrescando primeiro
|
||||
(`Cloud_Connect::refresh()`) se estiver a menos de `LEEWAY=60s` de expirar. `get/put/delete/
|
||||
request()` genéricos, todos `Authorization: Bearer <token>` + JSON. Nota no código: "Astro's
|
||||
form-CSRF guard exempts JSON, so no Origin header is needed here (unlike the token
|
||||
endpoint)" — ou seja o `Cloud_Connect` usa `Origin` header nos POSTs form-encoded ao token
|
||||
endpoint, mas este cliente usa corpo JSON e não precisa. `put_gateway_credential()`/
|
||||
`delete_gateway_credential()` são específicos do fluxo Gateway (§2.2.6).
|
||||
|
||||
#### 2.2.6 `EMCP_Tools_Gateway_Credential` — `class-gateway-credential.php` ("Phase 1 hosted multi-site gateway")
|
||||
|
||||
**Esta é a ponte directa entre o subsistema Cloud e o subsistema OAuth (§3).** Comentário no
|
||||
cabeçalho: *"Phase 1 of the hosted multi-site gateway: each site can self-issue a revocable
|
||||
refresh token against its OWN OAuth server, bound to a single, idempotently-provisioned
|
||||
client... Reuses the existing OAuth persistence layer (EMCP_Tools_OAuth_Store)."*
|
||||
|
||||
Mecanismo: o site cria (ou reusa, se já existir por nome+redirect_uris — mesmo padrão de
|
||||
dedup de `create_client()`, §3.3.5) um client OAuth estável chamado `"EMCP Gateway"` **no
|
||||
seu próprio servidor OAuth** (o de §3, não o da Cloud), e **auto-emite** um refresh token de
|
||||
longuíssima duração (`REFRESH_TTL = 315360000` — 10 anos, "efectivamente não-expirante")
|
||||
ligado a um utilizador WordPress específico, através de `EMCP_Tools_OAuth_Store::issue_token()`
|
||||
directamente (sem passar pelo fluxo `/authorize` normal — é um self-issue administrativo).
|
||||
Depois faz upload desse `{client_id, refresh_token, site_uuid, token_endpoint}` para a Cloud
|
||||
via `PUT /api/cloud/v1/gateway/credential`. Objectivo (Phase 2, ainda do lado da Cloud, não
|
||||
implementado neste build): a Cloud poder actuar como um **gateway multi-site** que troca
|
||||
este refresh token pelos seus próprios access tokens contra o servidor OAuth de CADA site
|
||||
ligado, sem o site ter de expor Application Passwords a um serviço terceiro.
|
||||
|
||||
`provision($user_id)` é best-effort e limpo: se o upload para a Cloud falhar, revoga
|
||||
imediatamente o token recém-emitido localmente (não deixa um token órfão vivo). `deprovision()`
|
||||
faz o inverso (delete remoto best-effort + revoke local incondicional — "offline-proof kill
|
||||
switch"). `handle_client_revoked($client_id)` é chamado pelo painel "Authorized Apps" do
|
||||
próprio site (revogação manual de qualquer client OAuth) para também limpar o lado Cloud se
|
||||
o client revogado for justamente o Gateway.
|
||||
|
||||
#### 2.2.7 `EMCP_Tools_Settings_Sync` — `class-settings-sync.php` (feature paga, "Turnkey settings sync")
|
||||
|
||||
`entitled()` — gate por entitlement `syncSettings` do plano Cloud ligado (lê
|
||||
`Cloud_Sync::status()`, cacheado estaticamente por request). `sync_keys()` — **allowlist
|
||||
explícita e filtrável** (`emcp_tools_settings_sync_keys`) de 10 chaves `wp_options`
|
||||
sincronizáveis: `emcp_tools_disabled_tools`, `elementor_mcp_disabled_tools` (chave legacy),
|
||||
`emcp_tools_active_modules`, `emcp_tools_dispatcher_mode`, `emcp_tools_strict_schemas`,
|
||||
`emcp_tools_content_mirror_enabled`, `emcp_tools_context_settings`,
|
||||
`emcp_tools_module_themer_force_render`, `emcp_tools_memory_require_approval`,
|
||||
`emcp_tools_memory_auto_summarize`. Comentário no cabeçalho: **"Never touches secrets or
|
||||
site-specific keys."** — nunca inclui tokens, a ligação Cloud, o UUID do site, logs de
|
||||
auditoria, ou estado de notificações. `apply()` só escreve chaves da allowlist (defesa
|
||||
contra um blob adulterado). `push()`/`pull_and_apply()` delegam em
|
||||
`Cloud_Sync::push_config()`/`pull_config()` com `type='settings'` — reusa o MESMO endpoint
|
||||
genérico que `cloud-config-sync` expõe como MCP tool, mas esta classe **não tem tool MCP
|
||||
própria** (é só UI de admin).
|
||||
|
||||
#### 2.2.8 `EMCP_Tools_Cloud_Module` — `includes/modules/class-cloud-module.php` (gating)
|
||||
|
||||
`id()='cloud'`, `tier()='free'`, `default_active()=true`. `register()` só chama
|
||||
`EMCP_Tools_Cloud_Connect::init()` (regista os 3 handlers `admin_post_*`). `is_enabled()`
|
||||
estático lê directamente a option `emcp_tools_active_modules` (evita depender do boot do
|
||||
módulo em `init:5`, porque as abilities registam-se em `wp_abilities_api_init`, que pode
|
||||
correr antes).
|
||||
|
||||
---
|
||||
|
||||
## 3. Servidor OAuth — infra-estrutura MCP genérica (`includes/oauth/`)
|
||||
|
||||
**Não é uma peça do EMCP Cloud.** É um servidor de autorização OAuth 2.1 completo
|
||||
(Authorization Code + PKCE obrigatório, só clientes públicos — sem client secret),
|
||||
implementado inteiramente dentro do WordPress, que serve de **alternativa ao Application
|
||||
Password** para qualquer cliente MCP remoto (Claude Desktop, VS Code, Cursor, CLIs como
|
||||
OpenClaw) se ligar ao endpoint MCP deste site sem o utilizador ter de gerar e colar uma
|
||||
Application Password manualmente. O único consumidor interno do plugin é
|
||||
`EMCP_Tools_Gateway_Credential` (§2.2.6) — tudo o resto é para clientes MCP externos
|
||||
genéricos.
|
||||
|
||||
### 3.1 Endpoints
|
||||
|
||||
| Documento/endpoint | Método | Rota | Tipo | RFC |
|
||||
|---|---|---|---|---|
|
||||
| Protected Resource Metadata | GET | `/.well-known/oauth-protected-resource` | **não-REST** (`parse_request`) | RFC 9728 |
|
||||
| Authorization Server Metadata | GET | `/.well-known/oauth-authorization-server` | **não-REST** (`parse_request`) | RFC 8414 |
|
||||
| Authorize + consentimento | GET/POST | `/emcp-oauth/authorize` | **não-REST** (`parse_request`, front-end normal) | RFC 6749 §4.1.1 |
|
||||
| Dynamic Client Registration | POST | `/wp-json/emcp-tools/oauth/v1/register` | REST (`permission_callback: __return_true`) | RFC 7591 |
|
||||
| Token (code exchange + refresh) | POST | `/wp-json/emcp-tools/oauth/v1/token` | REST (`__return_true`) | RFC 6749 |
|
||||
| Revoke | POST | `/wp-json/emcp-tools/oauth/v1/revoke` | REST (`__return_true`) | RFC 7009 |
|
||||
|
||||
### 3.2 `EMCP_Tools_OAuth_Server` — `class-oauth-server.php` (orquestrador)
|
||||
|
||||
- `is_available()` — `HTTPS OK` (via `is_ssl()`, `home_url()` a começar por `https://`, ou
|
||||
host local `localhost`/`127.0.0.1`/`::1`/`*.test`/`*.local`/`*.localhost`), filtrável via
|
||||
`emcp_tools_oauth_available` (para hosting atrás de um proxy que termina TLS antes do PHP).
|
||||
- `option_enabled()` — option `emcp_tools_oauth_enabled`: se nunca definida explicitamente,
|
||||
o default é **ON sempre que `is_available()`** (decisão de produto documentada em
|
||||
comentário: "OAuth sign-in is a free, core connectivity feature... enabled wherever it is
|
||||
available").
|
||||
- `is_enabled() = is_available() && option_enabled()` — o gate único que todo o resto do
|
||||
subsistema verifica.
|
||||
- Em `init:20`, se `is_available()`: instala as tabelas (`OAuth_Store::maybe_install()`) e
|
||||
agenda um WP-Cron diário de garbage-collection (`gc_hook`) — corre **mesmo que o toggle
|
||||
esteja OFF**, para limpar tokens residuais de quando esteve ligado. Se `is_enabled()`:
|
||||
regista os documentos de discovery, as rotas REST, o endpoint de authorize, e um filtro
|
||||
em `rest_post_dispatch` que emite o desafio `WWW-Authenticate` (§3.3.6).
|
||||
- `base_url()` usa `EMCP_Tools_Site_Context::rest_endpoint()` (não `rest_url()` cru) — honra
|
||||
um eventual override de "Server URL" no admin, para manter issuer/resource/token
|
||||
consistentes num site atrás de um domínio provisório/proxy.
|
||||
|
||||
### 3.3 Fluxo completo
|
||||
|
||||
#### 3.3.1 Discovery — `class-oauth-metadata.php`
|
||||
|
||||
`issuer()`/`resource()` usam `EMCP_Tools_Site_Context::public_base_url()` (mesmo motivo do
|
||||
`base_url()` acima). `path_matches()` aceita tanto o path exacto do well-known como uma
|
||||
**variante "resource-scoped"** (RFC 9728 §3.1) — ex.
|
||||
`/.well-known/oauth-protected-resource/wp-json/mcp/emcp-tools-server` — porque clientes MCP
|
||||
reais fazem esse pedido específico (comentário: "Match... the resource-scoped variant
|
||||
clients build by appending the resource path"; sem isto o discovery falha silenciosamente
|
||||
para esses clientes). Documento devolvido com `Access-Control-Allow-Origin: *` + cache 1h —
|
||||
é discovery público, sem dados sensíveis.
|
||||
|
||||
#### 3.3.2 Dynamic Client Registration — `class-oauth-clients.php`
|
||||
|
||||
`POST /register` totalmente aberto (`permission_callback: __return_true` — RFC 7591 prevê
|
||||
registo aberto para clientes públicos). Validação de `redirect_uris`: array não vazio, cada
|
||||
URI **https absoluto**, OU **http só em loopback** (`127.0.0.1`/`::1`/`localhost`, RFC 8252
|
||||
§7.3), OU um esquema custom de app privada (ex. `claude://`, RFC 8252 §7.1 — aceite sem
|
||||
restrição adicional, para clientes nativos). Nunca aceita URI com fragment component (RFC
|
||||
6749 §3.1.2). `client_name` default `'MCP Client'` se ausente. Resposta:
|
||||
`{client_id, client_name, redirect_uris, token_endpoint_auth_method:'none', grant_types:
|
||||
['authorization_code','refresh_token'], response_types:['code'], client_id_issued_at}`.
|
||||
|
||||
#### 3.3.3 Authorize + consentimento — `class-oauth-authorize.php`
|
||||
|
||||
**Decisão de design não óbvia**: este endpoint é servido como um pedido **front-end normal**
|
||||
via `parse_request` (prioridade 0) — **deliberadamente NÃO é uma rota REST**. Motivo (do
|
||||
próprio código): uma rota REST precisaria de um nonce que o browser do cliente MCP (que abre
|
||||
este URL numa aba/janela) não tem forma de fornecer, e a autenticação por cookie de sessão
|
||||
WordPress só funciona no fluxo normal de página.
|
||||
|
||||
`GET`: valida `client_id`+`redirect_uri` **PRIMEIRO**, antes de confiar em `redirect_uri`
|
||||
como alvo de qualquer redirect de erro — protecção contra open-redirect via um `client_id`
|
||||
malicioso ou desconhecido. Só depois valida `response_type=code` e
|
||||
`code_challenge_method=S256` (obrigatório; `plain` nunca é aceite). Se não autenticado,
|
||||
redirige para `wp_login_url()` com retorno para si mesmo. Exige `current_user_can('manage_options')`
|
||||
(filtrável via `emcp_tools_oauth_authorize_cap`) para poder aprovar — só administradores
|
||||
autorizam ligações MCP. Renderiza um ecrã de consentimento HTML autónomo (CSS inline, sem
|
||||
dependências do tema), mostrando o nome do client, o site, e o utilizador autenticado.
|
||||
|
||||
`POST`: valida nonce `_emcp_oauth_nonce` (acção `emcp_oauth_consent`), revalida
|
||||
client+redirect, se `action != 'approve'` redirige com `error=access_denied`. Se aprovado,
|
||||
emite o código via `OAuth_Store::issue_code()` (payload:
|
||||
`{client_id, user_id, redirect_uri, code_challenge, scopes}`) e redirige de volta com
|
||||
`?code=&state=`.
|
||||
|
||||
#### 3.3.4 Token + Revoke — `class-oauth-token.php`
|
||||
|
||||
`POST /token` dispatcher por `grant_type`:
|
||||
|
||||
- **`authorization_code`**: `OAuth_Store::consume_code()` (single-use, apaga o transient no
|
||||
consumo), valida `client_id` match, `redirect_uri` match, e PKCE S256
|
||||
(`hash_equals` sobre o `code_verifier` recomputado) — tudo em `validate_code_exchange()`
|
||||
(pura, testável isoladamente). Emite par access+refresh.
|
||||
- **`refresh_token`**: procura o token, valida `client_id` match, e **rota** o refresh token
|
||||
velho — mas NÃO com apagamento imediato:
|
||||
- `OAuth_Store::rotate_out_refresh($id, self::refresh_grace())` — o access token antigo
|
||||
ligado a esse refresh **não é cascade-deletado** (ao contrário de uma revogação
|
||||
explícita); sobrevive até expirar pela sua própria TTL. Justificação no código: "an
|
||||
in-flight MCP request may still be carrying [it]... invalidating it 401s those requests
|
||||
the instant the client refreshes... surfaced as connections dropping mid-chat" — cita
|
||||
RFC 6749 §1.5, que permite explicitamente este comportamento.
|
||||
- o refresh token rodado fica ainda utilizável por uma **janela de graça**
|
||||
(`REFRESH_GRACE=120s`, filtrável via `emcp_tools_oauth_refresh_grace` ou constante
|
||||
`EMCP_TOOLS_OAUTH_REFRESH_GRACE`) em vez de morrer instantaneamente — cobre o caso de um
|
||||
cliente cuja resposta do refresh anterior se perdeu na rede e reenvia o mesmo refresh
|
||||
token: em vez de `invalid_grant`, roda de novo com sucesso.
|
||||
- `ACCESS_TTL=3600s` (1h, filtrável/constante — útil para testar o fluxo de refresh em
|
||||
minutos em vez de esperar uma hora), `REFRESH_TTL=2592000s` (30 dias).
|
||||
|
||||
`POST /revoke` — **sempre devolve 200**, mesmo para um token desconhecido (RFC 7009,
|
||||
previne enumeração). Procura o token como access OU refresh e revoga a linha encontrada.
|
||||
|
||||
#### 3.3.5 Persistência — `class-oauth-store.php`
|
||||
|
||||
2 tabelas próprias criadas via `dbDelta` (idempotente, `DB_VERSION=2` — v2 mudou os
|
||||
timestamps para `BIGINT` "2038-safe" e adicionou índice em `refresh_of`):
|
||||
|
||||
```sql
|
||||
CREATE TABLE wp_emcp_oauth_clients (
|
||||
client_id VARCHAR(64) NOT NULL,
|
||||
client_name VARCHAR(191) NOT NULL,
|
||||
redirect_uris TEXT NOT NULL,
|
||||
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (client_id)
|
||||
);
|
||||
CREATE TABLE wp_emcp_oauth_tokens (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
token_hash CHAR(64) NOT NULL, -- SHA-256, ÚNICO
|
||||
token_type VARCHAR(10) NOT NULL, -- 'access' | 'refresh'
|
||||
client_id VARCHAR(64) NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
scopes VARCHAR(191) NOT NULL DEFAULT '',
|
||||
expires_at BIGINT NOT NULL,
|
||||
refresh_of BIGINT UNSIGNED NULL DEFAULT NULL, -- liga o access token ao seu refresh
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (id), UNIQUE KEY token_hash (token_hash),
|
||||
KEY client_id (client_id), KEY user_id (user_id),
|
||||
KEY expires_at (expires_at), KEY refresh_of (refresh_of)
|
||||
);
|
||||
```
|
||||
|
||||
Tokens **nunca guardados em claro** — só o hash SHA-256 (sem sal; justificado porque os
|
||||
tokens já têm 32 bytes de entropia aleatória, um hash simples é indexável e adequado).
|
||||
Códigos de autorização vivem em **transients**, não na tabela (`CODE_TTL=300s` — 5 min, mais
|
||||
generoso que os ~60s típicos porque clientes CLI como OpenClaw exigem copy-paste manual do
|
||||
código e 60s era fácil de perder).
|
||||
|
||||
`create_client()` é **deduplicado**: `find_client_by_registration()` procura por
|
||||
nome+redirect_uris normalizados antes de criar — comentário: "MCP clients (Claude, Codex)
|
||||
re-run Dynamic Client Registration each time they connect; without this the clients table
|
||||
grows unbounded (one dead row per connect)". Seguro porque são clientes públicos (sem
|
||||
segredo) e os tokens ficam ligados ao utilizador autorizador, não ao client em si.
|
||||
|
||||
`gc()` — apaga tokens expirados, depois clients órfãos (sem nenhum token e criados há mais
|
||||
de `ORPHAN_CLIENT_GRACE=1 dia` — protege um client recém-registado que ainda não terminou o
|
||||
fluxo de autorização). `gc_throttled()` corre `gc()` no máximo 1x por
|
||||
`GC_THROTTLE_INTERVAL=900s` (transient-guarded), e é chamado directamente do **hot-path de
|
||||
validação Bearer** (§3.3.6) — "clean at validation time": qualquer site com tráfego MCP
|
||||
activo mantém as tabelas limpas em minutos, sem depender só do cron diário como backstop.
|
||||
|
||||
#### 3.3.6 Bearer — `class-oauth-bearer.php` (validação no transporte MCP)
|
||||
|
||||
Ligado como `transport_permission_callback` do servidor MCP (`Plugin::register_mcp_server()`,
|
||||
doc 00 §3) — **é aqui que o servidor OAuth se conecta ao resto do plugin.**
|
||||
`permission_callback($request)`:
|
||||
|
||||
1. Extrai o Bearer do header `Authorization` (com fallback para `HTTP_AUTHORIZATION` /
|
||||
`REDIRECT_HTTP_AUTHORIZATION` de `$_SERVER`, para hosts que despem o header antes de
|
||||
chegar ao PHP — comum em CGI/FastCGI).
|
||||
2. Se presente: `gc_throttled()`, procura o access token na store; se válido faz
|
||||
`wp_set_current_user()` e devolve `true`; se inválido/expirado devolve `false`
|
||||
(**401 fail-closed — não cai para outro método de auth**).
|
||||
3. Se **ausente**: cai para o comportamento default do adapter (`Application Password` /
|
||||
cookie), via o filtro `mcp_adapter_default_transport_permission_user_capability` —
|
||||
**os dois métodos de auth coexistem sem se excluir mutuamente.**
|
||||
|
||||
`maybe_challenge()` (hook `rest_post_dispatch`) — em qualquer resposta 401/403 na rota
|
||||
`mcp/emcp-tools-server`, adiciona `WWW-Authenticate: Bearer resource_metadata="<url>"`
|
||||
(RFC 9728 §5.1) — é o mecanismo pelo qual um cliente MCP genérico **descobre
|
||||
automaticamente**, sem configuração manual, que este site suporta OAuth e onde começar o
|
||||
fluxo de discovery.
|
||||
|
||||
#### 3.3.7 Primitivas puras — `class-oauth-util.php`
|
||||
|
||||
Zero dependências de WordPress/BD — testável isoladamente. `base64url_encode/decode`
|
||||
(RFC 4648 §5, sem padding), `generate_token()` (32 bytes aleatórios → 43 chars),
|
||||
`generate_code_verifier()` (mesmo gerador), `code_challenge_s256()`, `generate_client_id()`
|
||||
(`'emcp_' + 24 hex`), `hash_token()` (SHA-256 simples), `verify_pkce()` (só aceita `S256`,
|
||||
nunca `plain`; exige verifier de 43-128 chars RFC 7636 §4.1; comparação em tempo constante),
|
||||
`secure_equals()` (`hash_equals` wrapper), `redirect_uri_matches()` — match exacto OU a
|
||||
**excepção de loopback nativo** (RFC 8252 §7.3): para `http://127.0.0.1`/`http://[::1]`/
|
||||
`http://localhost`, a porta pode diferir entre o registado e o apresentado, porque
|
||||
aplicações nativas fazem bind a uma porta local efémera.
|
||||
|
||||
---
|
||||
|
||||
## 4. Serviços transversais (usados pelos 3 subsistemas)
|
||||
|
||||
### 4.1 `EMCP_Tools_Secret` — `includes/class-secret.php`
|
||||
|
||||
Encriptação simétrica genérica para segredos em repouso — usada pelas 3 chaves API de stock
|
||||
image (§1.3) e pelo bundle de ligação Cloud (§2.2.1). Chave de 32 bytes **derivada por-site**
|
||||
a partir de `AUTH_KEY`+`SECURE_AUTH_KEY` (salts do `wp-config.php`) via
|
||||
`sodium_crypto_generichash` (fallback SHA-256) — **nunca guardada na base de dados**, deriva
|
||||
sempre em runtime. Prefere `libsodium` (`secretbox`, bundled desde PHP 7.2), fallback
|
||||
`OpenSSL AES-256-GCM`. Prefixo `emcps1:` marca valores encriptados, permitindo pass-through
|
||||
transparente de valores legacy/constantes em claro (`decrypt()` devolve o valor original se
|
||||
não tiver o prefixo). `decrypt_if_needed()` desembrulha repetidamente (guard de 8 iterações)
|
||||
para tolerar um bug de dupla-encriptação (ex. um callback de sanitização do Settings API que
|
||||
dispare duas vezes).
|
||||
|
||||
**Consequência prática:** um dump da base de dados sozinho nunca expõe uma chave API de
|
||||
stock-image nem um refresh token Cloud — precisa também do `wp-config.php`.
|
||||
|
||||
### 4.2 `EMCP_Tools_Url_Guard` — `includes/class-url-guard.php`
|
||||
|
||||
Guarda anti-SSRF usada por `sideload-image` (§1.1) e por outras tools que descarregam
|
||||
conteúdo remoto. Duas APIs distintas para dois níveis de rigor:
|
||||
|
||||
- **`is_safe_remote_url()` + `safe_download()`** (versão "leniente", usada no
|
||||
`sideload-image`) — valida esquema http(s), usa `wp_http_validate_url()` do core como
|
||||
primeira camada, e complementa com um `gethostbyname()` + `filter_var(...,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)` porque o core **não cobre**
|
||||
`169.254.0.0/16` (link-local — inclui o endpoint de metadata de cloud
|
||||
`169.254.169.254`, um alvo clássico de SSRF) nem endereços IPv6 internos.
|
||||
`safe_download()` força `reject_unsafe_urls=true` (WP_Http revalida CADA hop de
|
||||
redirect) e limita `redirection` a 2 hops.
|
||||
- **`validate()`** (versão "estrita", adicionada em 3.2.0 para a tool de AI Chat
|
||||
`web_fetch` — não coberta por esta série de docs, é Pro) — resolve **TODOS** os
|
||||
registos A **e** AAAA de um host (não só o primeiro, como `gethostbyname()` faz) e
|
||||
bloqueia se **qualquer** um for privado — cobre o caso de um host que publica um
|
||||
endereço público e um interno em simultâneo. Bloqueia também portas fora de
|
||||
`{80,443}` e URLs com credenciais embutidas (`user:pass@host`). Listas CIDR bloqueadas
|
||||
explícitas para IPv4 (`0.0.0.0/8`, `10/8`, `100.64/10` CGNAT, `127/8`, `169.254/16`,
|
||||
`172.16/12`, `192.168/16`, `224/4` multicast, `240/4` reservado) e IPv6
|
||||
(`::/128`, `::1/128`, `fc00::/7` ULA, `fe80::/10` link-local), incluindo o caso de
|
||||
endereço IPv4-mapped em IPv6 (`::ffff:127.0.0.1`).
|
||||
|
||||
Nota de honestidade técnica no próprio código: mesmo a versão estrita mantém uma **janela
|
||||
TOCTOU** entre a validação DNS e o connect TCP real (DNS rebinding) — WordPress liga por
|
||||
nome de host, não por IP pinado; fechar completamente exigiria `CURLOPT_RESOLVE`.
|
||||
|
||||
---
|
||||
|
||||
## Blueprint para réplica
|
||||
|
||||
**Reutilizar quase 1:1:**
|
||||
|
||||
1. **O servidor OAuth inteiro (§3)** — ~1500 linhas de infra-estrutura MCP genérica, sem
|
||||
NENHUMA dependência do EMCP Cloud. É a peça de maior valor deste documento para uma
|
||||
réplica: dá autenticação Bearer remota sem obrigar o utilizador a gerar/colar
|
||||
Application Passwords manualmente, e resolve sozinho toda a dança de discovery RFC
|
||||
9728/8414 que clientes MCP modernos esperam.
|
||||
2. **`EMCP_Tools_Secret` (§4.1)** — padrão de encriptação at-rest derivado dos salts do
|
||||
próprio site é elegante e trivial de copiar; não precisa de gestão de chave adicional
|
||||
nem de um KMS externo.
|
||||
3. **`EMCP_Tools_Url_Guard` (§4.2)** — a distinção entre modo "leniente" (sideload) e
|
||||
"estrito" (fetch de conteúdo para um modelo) é uma lição de segurança valiosa por si só;
|
||||
copiar tal e qual, incluindo a lista de CIDRs bloqueados e a nota honesta sobre o TOCTOU
|
||||
window de DNS rebinding.
|
||||
4. **`EMCP_Tools_Stock_Image_Providers` (§1.2)** — o padrão registry+resolver com fallback
|
||||
automático por ordem de prioridade é um bom desenho, trivialmente extensível a mais
|
||||
providers (ex. Openverse, que este plugin usava antes da v3.1.0 segundo os comentários
|
||||
dos clientes).
|
||||
5. **O padrão de rotação de refresh token com janela de graça** (`OAuth_Token::REFRESH_GRACE`,
|
||||
§3.3.4) **e o padrão de mutex/anti-corrida** (`Cloud_Connect::refresh()`, §2.2.2) — ambos
|
||||
resolvem bugs reais de concorrência já vividos em produção pelo autor original; aplicáveis
|
||||
a qualquer implementação própria de OAuth, tanto do lado servidor como cliente.
|
||||
|
||||
**Simplificar:**
|
||||
|
||||
- **Todo o subsistema Cloud (§2.2)** está acoplado a um SaaS de terceiros
|
||||
(`emcptools.com`) que uma réplica não vai ter por omissão. Vale a pena extrair só o
|
||||
**padrão arquitectural**, não o código ligado ao domínio: (a) `Cloud_Connect` é um bom
|
||||
template de "como SER cliente PKCE de outro servidor OAuth" (complementar ao §3, que
|
||||
documenta "como SER o servidor"); (b) o padrão de bundle+checksum para export/import de
|
||||
artefactos é reutilizável mesmo sem nuvem nenhuma, só para backup/restore local (ver
|
||||
doc 06). Sem um serviço cloud próprio planeado, esta secção inteira (~1200 linhas) é
|
||||
dispensável.
|
||||
- **`EMCP_Tools_Gateway_Credential` (§2.2.6)** é explicitamente uma feature "Phase 1"
|
||||
incompleta do lado deles (o próprio comentário do código diz "Phase 2 concern" para o
|
||||
lado da Cloud) — não vale a pena replicar sem um caso de uso concreto de "gateway
|
||||
multi-site" próprio.
|
||||
|
||||
**Deixar de fora:**
|
||||
|
||||
- `cloud-marketplace-*` (list/install/publish) — depende inteiramente de um marketplace
|
||||
SaaS de terceiros.
|
||||
- `EMCP_Tools_Settings_Sync` — feature paga ("Turnkey settings sync"), sem sentido numa
|
||||
réplica sem modelo de billing.
|
||||
|
||||
**Gotchas não óbvios (achados de código, não de documentação):**
|
||||
|
||||
1. **Heurística Unsplash download-tracking.** Agentes de IA passam frequentemente a URL
|
||||
`api.unsplash.com/photos/<id>/download` (parece um URL de imagem, mas é o endpoint de
|
||||
tracking que exige API key e devolve 401 sem ela) em vez do URL directo devolvido por
|
||||
`search-images`. O código resolve isto automaticamente (`resolve_download()`, §1.3) e
|
||||
`sideload-image` tem lógica dedicada de mensagem de erro para este caso específico — o
|
||||
próprio comentário chama-lhe "a common weak-model loop". Vale a pena replicar esta
|
||||
heurística de correcção accionável de erro, e generalizar o padrão (detectar a classe
|
||||
de erro mais comum de um agente e devolver uma sugestão específica, não só a mensagem
|
||||
crua da API).
|
||||
2. **Rotação de refresh token que se auto-sabota sem cuidado.** O comentário em
|
||||
`Cloud_Connect::refresh()` explica que um IdP com rotação estrita (Better Auth) invalida
|
||||
TODA a família de tokens se detectar reuso de um refresh token já rodado — o que
|
||||
transforma uma simples corrida entre duas requests concorrentes numa desconexão
|
||||
completa e forçada ("Reconnect needed"). A mitigação de 4 camadas (mutex, double-check,
|
||||
tratar rejeição concorrente como sucesso, nunca marcar unhealthy em falha transitória)
|
||||
é um padrão geral aplicável a qualquer cliente OAuth contra qualquer IdP com rotação.
|
||||
3. **A mesma lição, ao contrário, no servidor próprio.** `OAuth_Store::rotate_out_refresh()`
|
||||
com `REFRESH_GRACE` explicitamente NÃO apaga o access token antigo na rotação (evita 401
|
||||
a meio de uma conversa MCP em curso) e mantém o refresh token rodado utilizável por 120s
|
||||
extra (evita 401 num retry de resposta perdida). Cita RFC 6749 §1.5 como justificação —
|
||||
é o tipo de detalhe que só se aprende com utilizadores reais a queixarem-se de ligações a
|
||||
cair a meio.
|
||||
4. **Dedup de client OAuth em DCR.** `create_client()` reusa um client existente com o
|
||||
mesmo nome+redirect_uris em vez de criar sempre um novo — sem isto, clientes MCP que
|
||||
refazem DCR a cada ligação (confirmado no código: "Claude, Codex re-run Dynamic Client
|
||||
Registration each time they connect") fariam crescer a tabela de clients sem limite.
|
||||
Aplica-se tanto ao servidor OAuth do plugin (§3.3.5) como ao `Gateway_Credential`
|
||||
(§2.2.6), que idem reusa o client `"EMCP Gateway"` por nome.
|
||||
5. **`/authorize` como request front-end, não REST.** Decisão deliberada e não óbvia
|
||||
(§3.3.3) — uma rota REST exigiria um nonce que o browser do cliente MCP não tem como
|
||||
fornecer, e cookie auth de sessão só funciona no fluxo normal de navegação de página.
|
||||
Preservar esta decisão tal e qual numa réplica.
|
||||
6. **Ordem de validação anti-open-redirect.** Em `OAuth_Authorize::handle_get()`,
|
||||
`client_id`+`redirect_uri` são validados **antes** de qualquer outra coisa,
|
||||
especificamente para nunca usar um `redirect_uri` não confiável como alvo de um
|
||||
redirect de erro — protecção directa contra um vector de open-redirect via um
|
||||
`client_id` malicioso ou inexistente.
|
||||
7. **`normalize_result()` (doc 00 §5) protege também as tools Cloud.** O output de
|
||||
`cloud-status`/`cloud-list` (devolvido "as array" directamente do JSON decodificado da
|
||||
API remota) passa pelo mesmo wrapper de normalização de resultado do ability registrar
|
||||
— o que protege contra a mesma classe de bug documentada para o WooCommerce
|
||||
(`report-products-totals` devolve array de topo) caso a API da Cloud alguma vez faça o
|
||||
mesmo. Confirma que a camada de `class-schema-compat.php` deve ser aplicada
|
||||
**universalmente**, mesmo a tools que parecem "seguras" por delegarem numa API JSON
|
||||
bem-comportada.
|
||||
|
||||
---
|
||||
|
||||
## Fonte
|
||||
|
||||
Leitura directa (19-08-2026) de, em `/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`:
|
||||
|
||||
- `includes/abilities/class-ability-registrar.php` (gating de todos os grupos)
|
||||
- `includes/abilities/class-stock-image-abilities.php`
|
||||
- `includes/class-stock-image-providers.php`
|
||||
- `includes/class-unsplash-client.php`
|
||||
- `includes/class-pexels-client.php`
|
||||
- `includes/class-pixabay-client.php`
|
||||
- `includes/abilities/class-cloud-abilities.php`
|
||||
- `includes/cloud/class-cloud.php`
|
||||
- `includes/cloud/class-cloud-connect.php`
|
||||
- `includes/cloud/class-cloud-http.php`
|
||||
- `includes/cloud/class-cloud-sync.php`
|
||||
- `includes/cloud/class-gateway-credential.php`
|
||||
- `includes/cloud/class-cloud-client.php`
|
||||
- `includes/cloud/class-settings-sync.php`
|
||||
- `includes/modules/class-cloud-module.php`
|
||||
- `includes/oauth/class-oauth-metadata.php`
|
||||
- `includes/oauth/class-oauth-authorize.php`
|
||||
- `includes/oauth/class-oauth-server.php`
|
||||
- `includes/oauth/class-oauth-store.php`
|
||||
- `includes/oauth/class-oauth-bearer.php`
|
||||
- `includes/oauth/class-oauth-util.php`
|
||||
- `includes/oauth/class-oauth-clients.php`
|
||||
- `includes/oauth/class-oauth-token.php`
|
||||
- `includes/class-secret.php`
|
||||
- `includes/class-url-guard.php`
|
||||
|
||||
Cruzado com `docs/00-ARQUITECTURA.md` (arquitectura geral do plugin, cadeia de arranque,
|
||||
`emcp_tools_register_ability()`) e `skill://emcp-tools` (auditoria de postura de segurança,
|
||||
16-08-2026) para contexto de gating e postura por site.
|
||||
@@ -0,0 +1,661 @@
|
||||
# 10 — Sistema de módulos (Modules tab) e inventário Pro-only (metadata)
|
||||
|
||||
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
|
||||
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
|
||||
19-08-2026. Cruzado com `docs/00-ARQUITECTURA.md` (mesma batch) e `skill://emcp-tools`.
|
||||
|
||||
Este documento tem duas partes independentes: **Parte 1** documenta o sistema de "módulos" — a
|
||||
camada de toggles on/off do Modules tab, que é ortogonal ao sistema de abilities/MCP tools
|
||||
(uma ability pode existir sempre, só um módulo; um módulo pode não ter nenhuma ability associada;
|
||||
ver Themer/Redirect para o padrão onde módulo E abilities coexistem com gating cruzado). **Parte 2**
|
||||
é o inventário definitivo — para cada classe referenciada com `class_exists()` em
|
||||
`class-ability-registrar.php::register_groups()` que não seja confirmada Free por outro documento
|
||||
desta batch, confirma-se aqui, por leitura directa (listagem de directório + tentativa de leitura
|
||||
de ficheiro), se existe ou não na árvore Free instalada.
|
||||
|
||||
---
|
||||
|
||||
# PARTE 1 — Sistema de módulos
|
||||
|
||||
## 1.1 Classe base — `EMCP_Tools_Module`
|
||||
|
||||
Ficheiro: `includes/modules/class-module.php` (~100 linhas). Classe abstracta; contrato mínimo
|
||||
que todo módulo tem de implementar:
|
||||
|
||||
| Método | Abstracto? | Contrato |
|
||||
|---|---|---|
|
||||
| `id(): string` | sim | id estável (`a-z0-9-`) — usado como valor no array `emcp_tools_active_modules` e como infixo das option keys próprias do módulo |
|
||||
| `title(): string` | sim | título humano para o card no Modules tab |
|
||||
| `description(): string` | sim | descrição de uma linha para o card |
|
||||
| `tier(): string` | sim | `'free'` \| `'pro'` — controla o badge de tier na UI |
|
||||
| `default_active(): bool` | sim | se o módulo arranca activo por omissão (seeded uma única vez, ver §1.2) |
|
||||
| `register(): void` | sim | liga os hooks do módulo. Só chamado pela registry quando activo **e** disponível |
|
||||
| `render_settings(): void` | sim | desenha os knobs do módulo dentro do seu card (mostrado quando activo) |
|
||||
| `is_available(): bool` | não (default `true`) | sonda de dependência/capacidade — override para gate em features do servidor (ex.: suporte WebP) ou em licença Pro |
|
||||
| `settings_fields(): array` | não (default `[]`) | mapa `option_key => ['type','default','sanitize_callback']` para registo sanitizado no grupo de settings do módulo |
|
||||
| `is_active(): bool` | concreto | `in_array($this->id(), get_option('emcp_tools_active_modules', []), true)` |
|
||||
| `settings_group(): string` | concreto | `'emcp_tools_module_' . str_replace('-','_',$this->id()) . '_settings'` — grupo Settings API próprio, para o form do módulo gravar independentemente dos toggles de outros módulos |
|
||||
| `has_settings(): bool` | concreto | `[] !== $this->settings_fields()` |
|
||||
| `settings_url(): string` | não (default `''`) | quando definido, o card mostra um link "Configure →" para uma página admin dedicada, em vez de um overlay inline |
|
||||
|
||||
Constante: `OPTION_ACTIVE = 'emcp_tools_active_modules'` — a ÚNICA option que guarda quais módulos
|
||||
estão activos (array de ids).
|
||||
|
||||
## 1.2 Registry — `EMCP_Tools_Modules_Registry`
|
||||
|
||||
Ficheiro: `includes/modules/class-modules-registry.php` (~115 linhas). Singleton
|
||||
(`instance()` / `reset_for_tests()` para testes).
|
||||
|
||||
- **`register(EMCP_Tools_Module $module)`** — idempotente por id (`$this->modules[$module->id()] = $module`).
|
||||
- **`all()` / `get($id)` / `active()`** — leitura simples; `active()` filtra pelos que têm `is_active()===true`.
|
||||
- **`apply_defaults()`** — mecanismo de seeding. Option `emcp_tools_modules_seeded` (const `OPTION_SEEDED`)
|
||||
guarda uma lista de ids **já considerados** para seeding (não um booleano único). Para cada módulo
|
||||
registado ainda não na lista `seeded`: marca-o como seeded e, se `default_active()===true` e ainda
|
||||
não estiver em `active`, adiciona-o a `active`. Grava as duas options só se algo mudou.
|
||||
**Porquê lista por-módulo e não um marcador booleano:** um módulo novo introduzido numa versão
|
||||
posterior do plugin é seeded no load seguinte sem re-seedar — ou remover — o que o utilizador já
|
||||
tinha alterado manualmente nos módulos existentes. É o desenho correcto para migração aditiva sem
|
||||
tocar em estado do utilizador.
|
||||
- **`boot_active()`** — chamado em `init` (presume-se prioridade 5, confirmado pelos comentários nos
|
||||
módulos Redirect/Themer/Agent-Skills que dizem "abilities register on wp_abilities_api_init, antes
|
||||
do módulo arrancar em init:5"). Para cada módulo em `active()`: se `is_available()===true`, chama
|
||||
`register()`.
|
||||
|
||||
**Consequência de desenho importante:** os grupos de abilities MCP de um módulo (quando existem)
|
||||
**não podem depender de `register()` ter corrido**, porque `wp_abilities_api_init` dispara ANTES de
|
||||
`init:5`. Por isso todo módulo com abilities associadas expõe um **helper estático** `is_enabled()`
|
||||
(lê `get_option(OPTION_ACTIVE)` directamente, sem depender da instância da registry) que o
|
||||
`class-ability-registrar.php` chama em vez de `$module->is_active()`. Ver Redirect, Cloud, Themer,
|
||||
Agent-Skills, Image-Optimization (`module_is_active()`), Memory, Migrate — todos seguem este padrão.
|
||||
|
||||
## 1.3 Os 7 módulos "tab-only" / leves
|
||||
|
||||
Todos vivem em `includes/modules/class-{id}-module.php`. Cinco são efectivamente free
|
||||
(Prompts, Brand Kits, Redirect, Cloud, Themer); dois têm o **ficheiro de metadata presente na
|
||||
árvore Free mas `tier()==='pro'`** (Templates, Agent Skills) — um padrão deliberado do autor,
|
||||
explicitado no comentário do código de Agent Skills: "This class carries no Pro logic and lives
|
||||
safely in the free tree, like EMCP_Tools_Templates_Module."
|
||||
|
||||
### Prompts (`id: 'prompts'`)
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| `tier()` | `free` |
|
||||
| `default_active()` | `true` |
|
||||
| `is_available()` | não sobreposto (sempre `true`) |
|
||||
| `settings_url()` | `admin.php?page={PAGE_SLUG}-prompts` |
|
||||
| `register()` | **no-op** |
|
||||
|
||||
Feature "tab-only": o módulo só controla se a tab admin Prompts (e o respectivo stat card) aparece;
|
||||
quem lê `is_active()` é a classe de admin para mostrar/esconder a tab. O conteúdo free/Pro DENTRO
|
||||
da tab (amostras bundled vs biblioteca premium) é inalterado por este toggle — é outra camada de
|
||||
gating, não deste módulo.
|
||||
|
||||
### Brand Kits (`id: 'brand-kits'`)
|
||||
|
||||
Idêntico em estrutura ao Prompts: `tier()=free`, `default_active()=true`, `register()` no-op,
|
||||
`settings_url()` aponta para `-brand-kits`. Controla apenas a visibilidade da tab; os 10 kits
|
||||
bundled free (`EMCP_Tools_Free_Brand_Kits`, §1.6) vs 50+ kits Pro por licença são geridos por outro
|
||||
mecanismo, não por este toggle.
|
||||
|
||||
### Templates (`id: 'templates'`) — **tier Pro, ficheiro em Free**
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| `tier()` | **`pro`** |
|
||||
| `default_active()` | `true` |
|
||||
| `is_available()` | **sobreposto**: `function_exists('emcp_tools_fs') && emcp_tools_fs()->can_use_premium_code()` |
|
||||
| `settings_url()` | `admin.php?page={PAGE_SLUG}-templates` |
|
||||
| `register()` | **no-op** |
|
||||
|
||||
Comentário do autor no ficheiro: "Pro tier — free users see a locked card in Modules and the
|
||||
standalone tab is hidden. [...] This class is metadata only (no Pro logic), so it lives safely in
|
||||
the free tree." Ou seja: o ficheiro `.php` desta classe existe fisicamente no build Free (por isso
|
||||
não aparece na Parte 2 como "ausente"), mas `is_available()` bloqueia sempre que não há licença
|
||||
activa — o resultado prático é indistinguível de um módulo Pro-only, só a UI "locked card" difere.
|
||||
|
||||
### Redirect Manager (`id: 'redirects'`, const `ID`)
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| `tier()` | `free` |
|
||||
| `default_active()` | `true` |
|
||||
| `settings_url()` | `admin.php?page={PAGE_SLUG}-redirects` |
|
||||
| `is_enabled(): bool` (estático) | lê `OPTION_ACTIVE` directamente — usado pelo registrar |
|
||||
| `register()` | `EMCP_Tools_Redirect_Store::init()` (instala tabela em `init:20`) + `EMCP_Tools_Redirect_Handler::init()` (handler 301/302 no front-end), ambos condicionais a `class_exists()` |
|
||||
|
||||
Comentário do autor: desactivar este módulo é um **verdadeiro kill switch** — o handler de
|
||||
redirect pára, as MCP tools caem (gated no registrar via `is_enabled()`), a tab admin esconde-se, e
|
||||
delete/rename deixa de emitir sugestões de redirect. Ver Doc05 para `Redirect_Abilities`/
|
||||
`Redirect_Store`/`Redirect_Handler` em detalhe.
|
||||
|
||||
### EMCP Cloud (`id: 'cloud'`)
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| `tier()` | `free` |
|
||||
| `default_active()` | `true` |
|
||||
| `settings_url()` | `admin.php?page=emcp-tools-connection#emcp-conn-main` |
|
||||
| `is_enabled(): bool` (estático) | idem padrão Redirect |
|
||||
| `register()` | `EMCP_Tools_Cloud_Connect::init()` (arranca o fluxo admin do cliente OAuth) |
|
||||
|
||||
O grupo `EMCP_Tools_Cloud_Abilities` (ficheiro `includes/abilities/class-cloud-abilities.php`,
|
||||
**confirmado presente na árvore Free**) só regista quando **ambas** as condições são verdadeiras:
|
||||
`EMCP_Tools_Cloud_Module::is_enabled()` **e** `EMCP_Tools_Cloud::is_connected()` — isto é, o gate
|
||||
não é licença, é estado de ligação (o site tem de estar ligado a uma conta EMCP Cloud). Toda a
|
||||
infra-estrutura de suporte (`includes/cloud/`: `class-cloud.php`, `class-cloud-client.php`,
|
||||
`class-cloud-connect.php`, `class-cloud-http.php`, `class-cloud-sync.php`,
|
||||
`class-gateway-credential.php`, `class-settings-sync.php`) está presente e é free. Ver Doc09.
|
||||
|
||||
### Themer (`id: 'themer'`)
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| `tier()` | `free` |
|
||||
| `default_active()` | `true` |
|
||||
| `settings_url()` | `edit.php?post_type={Themer_CPT::POST_TYPE}` |
|
||||
| `is_enabled(): bool` (estático) | idem padrão Redirect/Cloud |
|
||||
| `register()` | ver abaixo — o mais rico dos 7 módulos leves |
|
||||
|
||||
`register()` (chamado só em `init:5`, activo+disponível):
|
||||
1. `(new EMCP_Tools_Themer_CPT())->register()` — regista o CPT.
|
||||
2. Se `class_exists('EMCP_Tools_Themer_HFE_Conflict')`: `::init()` — aviso de conflito com o Header
|
||||
Footer Elementor (que constrói os mesmos slots header/footer); até o admin escolher um sistema,
|
||||
Themer ganha deterministicamente.
|
||||
3. `EMCP_Tools_Themer_Index::register_hooks()` — hooks de rebuild do índice de condições.
|
||||
4. **Heal one-time**: se `get_option('emcp_tools_themer_index_healed') !== '1'`, chama
|
||||
`EMCP_Tools_Themer_Index::rebuild()` e grava o marcador. **Comentário do autor, citado**: "a prior
|
||||
build could leave the condition index empty (the rebuild raced the metabox meta writes), so
|
||||
existing templates silently stopped applying." É a reparação de uma race condition real de dados
|
||||
já corrompidos em sites existentes — o marcador de option garante que corre exactamente uma vez
|
||||
por upgrade, sem o admin ter de voltar a gravar cada template manualmente.
|
||||
5. Se `! is_admin()`: `(new EMCP_Tools_Themer_Render_Controller())->init()` (front-end).
|
||||
6. Se `is_admin() && class_exists('EMCP_Tools_Themer_Metabox')`: `(new EMCP_Tools_Themer_Metabox())->init()`.
|
||||
7. Se `class_exists('EMCP_Tools_Themer_Blocks')`: `::init()` — blocos Gutenberg dinâmicos.
|
||||
8. Se `class_exists('EMCP_Tools_Themer_Widgets')`: `::init()` — widgets clássicos dinâmicos.
|
||||
9. Se `class_exists('EMCP_Tools_Themer_PHP')`: `(new EMCP_Tools_Themer_PHP())->init()` — feature de
|
||||
templates PHP puro (confirmado presente: `includes/themer/php/class-themer-php.php`). Nota: o
|
||||
grupo de abilities `EMCP_Tools_Themer_PHP_Abilities` (também confirmado presente) tem o SEU
|
||||
PRÓPRIO toggle independente (`EMCP_Tools_Themer_PHP::enabled()`), separado do toggle base do
|
||||
Themer — ver Doc04.
|
||||
|
||||
Comentário do autor: desactivar o módulo pára o CPT, a tomada de controlo do front-end e a tab; o
|
||||
registrar omite as tools também — kill switch total. Ver Doc04 para detalhe completo do subsistema
|
||||
`includes/themer/`.
|
||||
|
||||
### Agent Skills (`id: 'agent-skills'`) — **tier Pro, ficheiro em Free**
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| `tier()` | **`pro`** |
|
||||
| `default_active()` | `true` |
|
||||
| `is_available()` | **sobreposto**: `function_exists('emcp_tools_fs') && emcp_tools_fs()->can_use_premium_code()` |
|
||||
| `settings_url()` | `admin.php?page={PAGE_SLUG}-skills` |
|
||||
| `register()` | **no-op** |
|
||||
| `is_enabled(): bool` (estático) | idem padrão Redirect/Cloud/Themer, usado por DOIS consumidores |
|
||||
|
||||
Controla a exposição em **runtime** das skills bundled a agentes de IA ligados: as tools MCP
|
||||
`list-skills` / `get-skill` e o catálogo `## Skills` injectado no contexto de discovery. Desligar
|
||||
remove ambos (e o footprint de ~900 tokens da injecção) **sem tocar** no caminho de
|
||||
instalação-local na tab Skills (i.e. skills já descarregadas para disco continuam lá, só deixam de
|
||||
ser expostas a agentes MCP).
|
||||
|
||||
Dois consumidores estáticos de `is_enabled()`:
|
||||
1. `class-ability-registrar.php` — gate de `EMCP_Tools_Skill_Abilities` (confirmado **ausente** da
|
||||
árvore Free, ver Parte 2).
|
||||
2. `EMCP_Tools_Skill_Catalog::discovery_catalog()` — gate da injecção do catálogo no contexto MCP.
|
||||
|
||||
**Nuance importante:** o ficheiro desta classe **existe** no build Free instalado (contraste com
|
||||
Memory/Migrate, cujos módulos nem sequer têm ficheiro na árvore Free — ver Parte 2 §2.2). É por
|
||||
isso que `EMCP_Tools_Agent_Skills_Module` NÃO aparece na tabela final de "classes ausentes" — mas
|
||||
funcionalmente comporta-se como Pro, porque `is_available()` exige licença e a única ability que
|
||||
depende dela (`Skill_Abilities`) está de qualquer forma ausente do ficheiro-sistema Free.
|
||||
|
||||
## 1.4 Image Optimization — o módulo opt-in mais substancial (deep dive)
|
||||
|
||||
Ficheiros em `includes/modules/image-optimization/`: `class-image-optimization-module.php`,
|
||||
`class-image-optimizer.php`, `class-image-resizer.php`, `class-webp-generator.php`,
|
||||
`class-webp-rewriter.php`, `class-bulk-optimizer.php`, `settings-fields.php`.
|
||||
|
||||
### `EMCP_Tools_Image_Optimization_Module` (id `'image-optimization'`, const `ID`; option prefix `PREFIX = 'emcp_tools_module_image_optimization_'`)
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| `tier()` | `free` |
|
||||
| `default_active()` | **`false`** — único dos 9 módulos (contando os 2 de deep-dive) que é **opt-in por omissão**, além do SVG Support |
|
||||
| `is_available()` | **sobreposto**: `(new EMCP_Tools_Webp_Generator(82))->is_available()` — delega para `wp_image_editor_supports(['mime_type'=>'image/webp'])`; se o editor de imagem do servidor (GD/Imagick) não sabe exportar WebP, o módulo não fica disponível de todo |
|
||||
| `module_is_active(): bool` (estático) | usado pelo registrar para condicionar a ability `resize-media` |
|
||||
|
||||
`settings_fields()` — 6 option keys, todas sob `PREFIX`:
|
||||
|
||||
| Key | Tipo | Default |
|
||||
|---|---|---|
|
||||
| `compress` | bool (`'1'`/`'0'`) | `1` |
|
||||
| `webp` | bool | `1` |
|
||||
| `webp_serve` | bool | `1` |
|
||||
| `quality` | int | `60` |
|
||||
| `max_dimension` | int | `0` (0 = sem cap) |
|
||||
| `keep_originals` | bool | `1` |
|
||||
|
||||
`current_settings()` resolve estas options num array tipado (quality passa por
|
||||
`EMCP_Tools_Image_Optimizer::clamp_quality()` — clamp 1-100 partilhado, single source of truth).
|
||||
|
||||
`register()` (chamado só activo+disponível):
|
||||
- Se `compress` OU `webp`: instancia `EMCP_Tools_Image_Optimizer($settings)`, hook em
|
||||
`wp_generate_attachment_metadata` (prioridade 20) → `on_generate_metadata()`.
|
||||
- Se `webp`: `(new EMCP_Tools_Webp_Rewriter($settings['webp_serve']))->register()`.
|
||||
- Se `is_admin()`: `(new EMCP_Tools_Bulk_Optimizer($settings))->register()`.
|
||||
|
||||
`render_settings()` faz `include` de `settings-fields.php` com `$settings` em scope (view partial
|
||||
pura: switches para compress/webp/webp_serve, slider 1-100 para quality, number input para
|
||||
max_dimension, switch para keep_originals; usa um closure local `$emcp_io_toggle` para DRY do HTML).
|
||||
|
||||
### `EMCP_Tools_Image_Optimizer` — pipeline compress-on-upload
|
||||
|
||||
- Const `META_KEY = '_emcp_optim'` — meta do post attachment onde fica o resultado.
|
||||
- Construtor normaliza settings (bool/bool/int-clamp/int/bool).
|
||||
- `clamp_quality(int): int` (estático) — clamp 1-100, partilhado com `Image_Resizer`.
|
||||
- `sizes_to_process(metadata, basedir): string[]` — caminhos absolutos do full-size + todos os
|
||||
sub-sizes gerados.
|
||||
- `backup_path(file, upload): string` (estático) — espelha o caminho relativo às uploads sob
|
||||
`uploads/emcp-originals/`.
|
||||
- `should_skip(optim): bool` — `true` se `optim['status']==='done'` (idempotência).
|
||||
- `on_generate_metadata($metadata, $attachment_id)` — o hook callback:
|
||||
1. guarda: `$metadata` tem de ser array; settings tem de ter `compress` OU `webp`;
|
||||
2. **`apply_filters('emcp_tools_optimize_attachment', true, $attachment_id)`** — opt-out
|
||||
per-attachment. Usado por `sideload-image`/`add-stock-image` quando o chamador passa
|
||||
`convert_webp:false` (conversão a dar timeout em shared hosting) — é o ÚNICO ponto de
|
||||
extensibilidade externo deste pipeline;
|
||||
3. mime tem de ser `image/jpeg` ou `image/png` (WebP e GIF **não** entram no pipeline de
|
||||
compressão — só os dois formatos de origem processados);
|
||||
4. se já `status=done` no meta existente, salta;
|
||||
5. resolve `wp_upload_dir()`, calcula `sizes_to_process()`, chama `process_files()`, grava o
|
||||
resultado em `_emcp_optim`.
|
||||
- `process_files($files, $upload, $full_rel, $basedir): array` — o loop real por ficheiro:
|
||||
- mede tamanho "antes"; se `keep_originals`, faz backup (skip se já existir — idempotente);
|
||||
- `wp_get_image_editor()`; `set_quality()`; **só** o full-size (`$file === $full_abs`) recebe o
|
||||
cap `max_dimension` via `resize($max,$max,false)` (scale-to-fit, nunca crop) — os sub-sizes
|
||||
NUNCA são redimensionados por este cap;
|
||||
- se `compress`: `editor->save($file)` (re-encode IN PLACE, mesmo caminho);
|
||||
- mede tamanho "depois";
|
||||
- se `webp_ok`: `generator->generate($file)` → sibling;
|
||||
- devolve agregado: `status=done, original_bytes, optimized_bytes, webp_bytes, backups[], webps[]`.
|
||||
|
||||
### `EMCP_Tools_Webp_Generator`
|
||||
|
||||
- `sibling_path(file): string` (estático) — `"$file.webp"` (ex.:
|
||||
`name-800x600.jpg.webp` — a extensão original é **preservada**, `.webp` é anexado como extensão
|
||||
composta, para o rewriter encontrar deterministicamente sem precisar de índice).
|
||||
- `is_available()` — `wp_image_editor_supports(['mime_type'=>'image/webp'])`.
|
||||
- `generate(file)` — skip se sibling já existe (idempotente); `wp_get_image_editor()`;
|
||||
`set_quality()`; `save($sibling, 'image/webp')`.
|
||||
|
||||
### `EMCP_Tools_Webp_Rewriter`
|
||||
|
||||
- Construtor: `serve_frontend=true` por omissão; captura `basedir`/`baseurl` de `wp_upload_dir()`.
|
||||
- `register()` — hooks `wp_get_attachment_url`, `wp_get_attachment_image_src`,
|
||||
`wp_calculate_image_srcset` (todos prioridade 20).
|
||||
- `should_rewrite($accept, $is_rest, $serve_frontend): bool` (estático) — **REST/CLI/cron
|
||||
qualifica-se SEMPRE** (as MCP media tools resolvem sempre para WebP, independentemente do toggle
|
||||
frontend); frontend requer adicionalmente o toggle `serve_frontend` **e** header
|
||||
`Accept: image/webp`.
|
||||
- `webp_url(url): string` (estático) — regex troca `.jpg`/`.jpeg`/`.png` (antes de qualquer query
|
||||
string) pelo sibling `.webp`; outras extensões passam inalteradas.
|
||||
- `is_rest_context()` — `REST_REQUEST` const OU `WP_CLI` const OU `wp_doing_cron()`.
|
||||
- `url_to_path(url)` — mapeia URL de uploads de volta a caminho absoluto, só dentro do `baseurl`
|
||||
(scoping de segurança).
|
||||
- `maybe_rewrite(url)` — decisão final por URL: só reescreve se `allowed()` **e** a URL webp
|
||||
difere **e** o ficheiro `.webp` existe mesmo em disco (nunca devolve uma URL para um ficheiro
|
||||
inexistente).
|
||||
|
||||
### `EMCP_Tools_Bulk_Optimizer` — processador resumível da biblioteca existente
|
||||
|
||||
- Consts: `ACTION_BATCH='emcp_tools_optimize_batch'`, `ACTION_RESTORE='emcp_tools_optimize_restore'`,
|
||||
`NONCE='emcp_tools_modules'`, `OPTION_CURSOR='emcp_tools_module_image_optimization_bulk_cursor'`.
|
||||
- `register()` — 2 handlers `wp_ajax_*`.
|
||||
- `batch_size(n): int` (estático) — clamp 1-50, default 10 se `<=0`.
|
||||
- `progress(total, processed): array` (estático, pura) — `{total,processed,remaining,percent,done}`.
|
||||
- `ajax_batch()` — nonce + `manage_options`; query de TODOS os attachment IDs jpeg/png ordenados
|
||||
por ID ASC; lê cursor de `OPTION_CURSOR`; fatia o batch a partir do cursor; para cada ID:
|
||||
metadata → `sizes_to_process()` → `process_files()` → grava `_emcp_optim`; avança o cursor; reset
|
||||
do cursor a 0 quando `progress.done`; devolve JSON de progresso.
|
||||
- `ajax_restore()` — nonce + `manage_options`; percorre `uploads/emcp-originals/` recursivamente
|
||||
(`RecursiveIteratorIterator`); para cada backup: copia de volta sobre o caminho vivo, apaga o
|
||||
sibling `.webp` do destino, incrementa `restored`; reset do cursor; devolve `{restored}`.
|
||||
|
||||
## 1.5 SVG Support — o módulo com maior superfície de segurança (deep dive)
|
||||
|
||||
Ficheiros: `includes/modules/svg-support/class-svg-support-module.php`,
|
||||
`includes/modules/svg-support/class-svg-sanitizer.php`.
|
||||
|
||||
### `EMCP_Tools_SVG_Support_Module` (id `'svg-support'`, const `ID`; prefix `PREFIX = 'emcp_tools_module_svg_support_'`)
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| `tier()` | `free` |
|
||||
| `default_active()` | **`false`** — opt-in, comentário explícito "a security surface" |
|
||||
| `is_available()` | **sobreposto**: `EMCP_Tools_SVG_Sanitizer::library_available()` — fail-closed se a biblioteca de sanitização não estiver disponível |
|
||||
|
||||
WordPress bloqueia uploads SVG por omissão (SVG é XML e pode transportar scripts). Elementor já
|
||||
permite SVG para utilizadores autorizados via o seu próprio handling de unfiltered-upload — este
|
||||
módulo é dirigido a sites **sem** Elementor (ou onde o mime `svg` não está registado por outra via).
|
||||
|
||||
`svg_already_supported(): bool` (estático) — verifica `get_allowed_mime_types()` por `'svg'` ou
|
||||
`'svg|svgz'` — se já suportado por outro plugin/tema, mostra uma nota informativa em
|
||||
`render_settings()` (o módulo continua a sanitizar de qualquer forma quando activo).
|
||||
|
||||
`settings_fields()` — UMA option: `admin_only` (bool, default `'0'`).
|
||||
|
||||
`required_capability(): string` — resolve para `manage_options` se `admin_only` estiver ligado,
|
||||
senão `upload_files`; filtrável via `emcp_tools_svg_upload_capability`.
|
||||
|
||||
`register()` liga:
|
||||
1. `upload_mimes` → `allow_svg_mime()` — adiciona `'svg'=>'image/svg+xml'` só se
|
||||
`current_user_can(required_capability())`.
|
||||
2. `wp_check_filetype_and_ext` (prioridade 10, 4 args) → `fix_svg_filetype()` — **"the piece most
|
||||
SVG plugins miss"** (comentário do autor, citado): a sniff real-content de mime type do
|
||||
WordPress (via `finfo`) frequentemente reporta `text/plain` ou `image/svg` para SVGs e rejeita o
|
||||
upload; esta correcção resolve explicitamente `ext`/`type` para ficheiros `.svg` quando o
|
||||
utilizador tem a capability — é o que faz uploads REST/sideload funcionarem, não só o
|
||||
`media-new.php` clássico.
|
||||
3. `wp_handle_upload_prefilter` + `wp_handle_sideload_prefilter` → ambos `sanitize_upload()`.
|
||||
4. `admin_head` → `media_thumbnail_css()`, só se `is_admin()`.
|
||||
|
||||
`sanitize_upload($file)` — o gate real: se a extensão não for `svg`, passa; se o utilizador não
|
||||
tem a capability, rejeita com mensagem de erro; senão corre `(new EMCP_Tools_SVG_Sanitizer())
|
||||
->sanitize_file($tmp_name)` — se falhar, rejeita **fail-closed** ("could not be sanitized and was
|
||||
rejected for security").
|
||||
|
||||
`media_thumbnail_css()` — injecção CSS mínima para as thumbnails SVG renderizarem corretamente
|
||||
dimensionadas na grelha/lista da Media Library.
|
||||
|
||||
### `EMCP_Tools_SVG_Sanitizer` — wrapper fino sobre `enshrined/svg-sanitize` (vendorizada)
|
||||
|
||||
Mesma biblioteca que o plugin "Safe SVG" usa. SVG é XML — pode transportar script, event handlers,
|
||||
referências externas e payloads XXE; permitir upload SVG cru sem sanitizar é um vector de
|
||||
stored-XSS.
|
||||
|
||||
`library_available(): bool` — 3 estratégias de carregamento em cascata:
|
||||
1. classe já carregada (`class_exists('\enshrined\svgSanitize\Sanitizer')`);
|
||||
2. `EMCP_Tools_Adapter_Bootstrap::ensure()` (**o mesmo mecanismo de preload do Jetpack Autoloader
|
||||
usado para o MCP adapter vendorizado**, ver `00-ARQUITECTURA.md` §2.1) ou fallback directo a
|
||||
`vendor/autoload_packages.php`;
|
||||
3. **fallback próprio**: `register_fallback_autoloader()` — regista um autoloader PSR-4 escopado
|
||||
directamente contra `vendor/enshrined/svg-sanitize/src/`, para a sanitização SVG continuar a
|
||||
funcionar "even if the Jetpack classmap wasn't regenerated" (comentário do autor) — redundância
|
||||
defensiva deliberada contra um modo de falha real de geração de classmap Composer/Jetpack.
|
||||
|
||||
`sanitize(string $svg): string|false`:
|
||||
- instancia `\enshrined\svgSanitize\Sanitizer`;
|
||||
- `minify(false)`;
|
||||
- `removeRemoteReferences(true)` — **endurecimento SSRF/XSS explícito** para lá da configuração
|
||||
por omissão da biblioteca (remove `xlink:href` remoto, `use@href` remoto);
|
||||
- `false` em vazio/falha.
|
||||
|
||||
`sanitize_file(path): bool` — ler → sanitizar → sobrescrever in-place; `false` se ilegível ou
|
||||
sanitização falhar.
|
||||
|
||||
## 1.6 `EMCP_Tools_Free_Brand_Kits` — serviço de suporte (não é módulo)
|
||||
|
||||
Ficheiro: `includes/class-free-brand-kits.php` (nível topo de `includes/`, não em `modules/`).
|
||||
|
||||
Contraparte free do serviço `EMCP_Tools_Pro_Brand_Kits`. Onde o Pro busca 50+ kits de
|
||||
`emcptools.com` atrás de licença, este lê um conjunto pequeno e curado embutido no plugin
|
||||
(`assets/brand-kits/free-brand-kits.json`) — disponível a todos, sem licença, o mesmo modelo dos 5
|
||||
prompts de amostra bundled.
|
||||
|
||||
- `get_bundle(): array` — parse cacheado em memória (`self::$bundle`) do JSON; forma:
|
||||
`['categories' => [['slug','label','kits' => [{kit}, ...]]]]`. Para cada kit, se existir
|
||||
`assets/brand-kits/{slug}.svg` (previews pré-renderizadas, fontes outlined), injecta
|
||||
`thumbnail_url` + `preview.thumbnail_url` com a URL do plugin (o JSON não pode saber a URL do
|
||||
plugin). **Nunca devolve `WP_Error`** — os dados estão bundled, por isso estão sempre disponíveis
|
||||
(devolve bundle vazio se o ficheiro faltar).
|
||||
- `find_kit(kit_slug, category_slug='')` — procura linear.
|
||||
- `count_kits(): int` — total, para a barra de stats do admin.
|
||||
|
||||
**Facto crucial para o blueprint**: esta classe só **PROVIDENCIA** os dados do kit. A **aplicação**
|
||||
(escrever cores/tipografia no Elementor kit activo) passa sempre pelo
|
||||
`EMCP_Tools_System_Kit_Writer` PARTILHADO (confirmado presente em `includes/class-system-kit-writer.php`)
|
||||
e pelo `EMCP_Tools_Kit_Backup_Store` (backups reversíveis, confirmado presente em
|
||||
`includes/class-kit-backup-store.php`) — **exactamente o mesmo caminho usado pelo Pro**. Ou seja: a
|
||||
funcionalidade de "aplicar um kit" via UI admin funciona **sem licença nenhuma** (kits free + writer
|
||||
partilhado); é só a **ferramenta MCP** para o fazer programaticamente
|
||||
(`EMCP_Tools_System_Kit_Abilities`) que está totalmente ausente do build Free (ver Parte 2).
|
||||
|
||||
---
|
||||
|
||||
# PARTE 2 — Inventário Pro definitivo
|
||||
|
||||
## 2.1 Metodologia
|
||||
|
||||
1. Leitura integral de `includes/abilities/class-ability-registrar.php::register_groups()`
|
||||
(627 linhas) — extracção de TODAS as chamadas `class_exists('EMCP_Tools_...')` que condicionam
|
||||
o registo de um grupo de abilities ou de um pack de integração.
|
||||
2. Cruzamento com listagens de directório completas e literais (equivalente a `ls`) de:
|
||||
- `includes/abilities/` (47 ficheiros `.php` + subpastas `forms/`, `seo/`);
|
||||
- `includes/abilities/forms/` (2 ficheiros: `class-cf7-integration.php`,
|
||||
`class-form-integration.php` — só a base + CF7);
|
||||
- `includes/abilities/seo/` (2 ficheiros: `class-seo-integration.php`,
|
||||
`class-slimseo-integration.php` — só a base + SlimSEO);
|
||||
- `includes/modules/` (7 ficheiros de módulo + `class-module.php` + `class-modules-registry.php`
|
||||
+ subpastas `image-optimization/`, `svg-support/` — **sem** `class-memory-module.php` nem
|
||||
`class-migrate-module.php`).
|
||||
3. Para cada classe candidata a Pro-only (i.e. referenciada no registrar mas ausente das listagens
|
||||
acima), **tentativa directa de leitura** do ficheiro esperado (equivalente a `test -f`) —
|
||||
20 tentativas, cada uma resultando em `head: impossível abrir '...' para leitura: No such file
|
||||
or directory` (SSH remoto, `head` a falhar por ausência do ficheiro). Nenhuma presunção — cada
|
||||
linha da tabela abaixo tem confirmação de ausência por, no mínimo, listagem de directório
|
||||
completa, e a maioria tem confirmação dupla (listagem + tentativa de leitura directa).
|
||||
|
||||
Classes confirmadas **presentes** (portanto free, excluídas desta tabela por já estarem cobertas
|
||||
noutros documentos da batch): `EMCP_Tools_Image_Resize_Abilities`, `EMCP_Tools_ACF_Abilities`,
|
||||
`EMCP_Tools_Meta_Box_Abilities`, `EMCP_Tools_CF7_Integration`, `EMCP_Tools_SlimSEO_Integration`,
|
||||
`EMCP_Tools_Active_Theme_Integration`, `EMCP_Tools_Astra_Integration`,
|
||||
`EMCP_Tools_Spectra_Integration`, `EMCP_Tools_Kadence_Integration`,
|
||||
`EMCP_Tools_Kadence_Blocks_Integration`, `EMCP_Tools_PHP_Snippet_Abilities`,
|
||||
`EMCP_Tools_Sandbox_Cloud_Abilities`, `EMCP_Tools_Cloud_Abilities`, `EMCP_Tools_Cloud_Module`,
|
||||
`EMCP_Tools_Global_Classes_Abilities`, `EMCP_Tools_Global_Classes_Write_Abilities`,
|
||||
`EMCP_Tools_Themer_Abilities`, `EMCP_Tools_Themer_Module`, `EMCP_Tools_Themer_PHP_Abilities`,
|
||||
`EMCP_Tools_Themer_PHP`, `EMCP_Tools_Redirect_Module`, `EMCP_Tools_Redirect_Abilities`,
|
||||
`EMCP_Tools_Image_Optimization_Module`, `EMCP_Tools_Agent_Skills_Module` (presente mas `tier=pro`,
|
||||
ver §1.3).
|
||||
|
||||
## 2.2 Tabela final — Classes referenciadas no registrar mas ausentes do build Free (Pro-only)
|
||||
|
||||
30 classes, agrupadas por família funcional. Coluna "Condição de gating" reproduz literalmente a
|
||||
lógica de `register_groups()` (nomes de variáveis simplificados por clareza).
|
||||
|
||||
| Classe | Grupo funcional | Condição de gating (registrar) | Módulo Pro associado |
|
||||
|---|---|---|---|
|
||||
| `EMCP_Tools_Woo_Integration` | Integração WooCommerce (CRUD produtos/encomendas/etc.) | `class_exists(...) && EMCP_Tools_Woo_Integration::woo_active()` | — (gate próprio: plugin WooCommerce activo; sem module toggle dedicado) |
|
||||
| `EMCP_Tools_WPForms_Integration` | Integração de formulários — leitura de entries (Pro) | `emcp_tools_fs()->can_use_premium_code() && class_exists(...)`, depois `$integration->is_available()` | — (tab "Forms"; CF7 é a única integração free) |
|
||||
| `EMCP_Tools_GravityForms_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_FluentForms_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_NinjaForms_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_Formidable_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_MetForm_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_SureForms_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_Forminator_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_Yoast_Integration` | Integração SEO — leitura/escrita meta SEO (Pro) | `emcp_tools_fs()->can_use_premium_code() && class_exists(...)`, depois `$integration->is_available()` | — (tab "SEO"; SlimSEO é a única integração free) |
|
||||
| `EMCP_Tools_RankMath_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_AIOSEO_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_SeoPress_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_SEOFramework_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_SureRank_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_GeneratePress_Integration` | Integração de tema/framework (Pro) | `class_exists(...)` — comentário do código: "classes only present when Pro loaded" — depois `$integration->is_available()` | — |
|
||||
| `EMCP_Tools_GenerateBlocks_Integration` | idem | idem | — |
|
||||
| `EMCP_Tools_Blocksy_Blocks_Integration` | Integração Blocksy — blocos | `class_exists(...)`, depois `is_available()` | — |
|
||||
| `EMCP_Tools_Blocksy_Extensions_Integration` | Integração Blocksy — Companion extensions | `class_exists(...)`, depois `is_available()` | — |
|
||||
| `EMCP_Tools_EssentialAddons_Integration` | Pack de widgets Elementor de terceiros — Essential Addons (Pro) | `class_exists(...)`, depois `is_available()` ("contributes ONE read tool for discovery + curation") | — |
|
||||
| `EMCP_Tools_PremiumAddons_Integration` | Pack de widgets Elementor — Premium Addons (Pro) | idem | — |
|
||||
| `EMCP_Tools_UAE_Integration` | Ultimate Addons for Elementor (ex-Header Footer Elementor) — widget pack + data plugin | `class_exists(...)`, depois `is_available()` — único pack que mantém o par read/write dispatcher (discovery+templates na leitura, templates na escrita) | — |
|
||||
| `EMCP_Tools_Block_Builder_Abilities` | Construtor de blocos Gutenberg via MCP (Pro) | `class_exists(...)` — comentário: "self-guards on license" — Gutenberg, NÃO gated por Elementor activo | — |
|
||||
| `EMCP_Tools_System_Kit_Abilities` | Brand Kit / System Kit — ferramenta MCP (Pro) | `class_exists(...)` dentro do bloco `if ($elementor_active)` — "self-guards on license" | Módulos "Brand Kits" (free) e "Templates" (Pro) são metadata-only; a ferramenta MCP para aplicar kits é sempre Pro, mesmo com os 10 kits free disponíveis via UI (§1.6) |
|
||||
| `EMCP_Tools_Seo_Abilities` | Toolkit SEO on-page — ferramenta MCP (Pro) | idem, dentro de `$elementor_active` | — |
|
||||
| `EMCP_Tools_A11y_Abilities` | Toolkit de acessibilidade — ferramenta MCP (Pro) | idem | — |
|
||||
| `EMCP_Tools_Widget_Builder_Abilities` | Construtor de widgets Elementor custom — ferramenta MCP (Pro) | idem | — |
|
||||
| `EMCP_Tools_Skill_Abilities` | Ferramentas MCP `list-skills`/`get-skill` (Pro) | `class_exists('EMCP_Tools_Skill_Abilities') && class_exists('EMCP_Tools_Agent_Skills_Module') && EMCP_Tools_Agent_Skills_Module::is_enabled()` | **Agent Skills** — módulo PRESENTE na árvore Free (`tier()='pro'`, ver §1.3); a ability em si está sempre ausente independentemente do toggle |
|
||||
| `EMCP_Tools_Memory_Abilities` | Project Memory — ferramenta MCP (Pro) | `class_exists('EMCP_Tools_Memory_Abilities') && class_exists('EMCP_Tools_Memory_Module') && EMCP_Tools_Memory_Module::is_enabled()` | **Memory** — módulo TAMBÉM ausente da árvore Free (ver linha seguinte) |
|
||||
| `EMCP_Tools_Memory_Module` | Módulo "Memory" (toggle no Modules tab) | `class_exists('EMCP_Tools_Memory_Module')`, usado em conjunto com `Memory_Abilities` acima | Pro — ao contrário de Agent Skills/Templates, nem o ficheiro de metadata do módulo está na árvore Free; não há card "Memory" a mostrar-se bloqueado na Modules tab de um site Free |
|
||||
| `EMCP_Tools_Migrate_Abilities` | Backup / Migrate / Sync — ferramenta MCP (Pro) | `class_exists('EMCP_Tools_Migrate_Abilities') && class_exists('EMCP_Tools_Migrate_Module') && EMCP_Tools_Migrate_Module::is_enabled()`. As duas tools destrutivas deste grupo vêm desactivadas por omissão mesmo quando disponíveis. | **Migrate** — módulo TAMBÉM ausente (linha seguinte) |
|
||||
| `EMCP_Tools_Migrate_Module` | Módulo "Migrate" (toggle no Modules tab) | idem padrão de `Memory_Module` | Pro — mesmo padrão: nem o ficheiro de metadata existe na árvore Free |
|
||||
|
||||
**Nota sobre `Memory_Module`/`Migrate_Module` vs `Templates_Module`/`Agent_Skills_Module`:** o
|
||||
código tem DOIS padrões distintos para features Pro sem equivalente free algum:
|
||||
- **Padrão "card bloqueado"** (Templates, Agent Skills): o ficheiro `.php` do módulo VIVE na árvore
|
||||
Free, `tier()==='pro'`, `is_available()` exige licença — o utilizador Free VÊ o card na Modules
|
||||
tab, mas bloqueado/locked, como incentivo de upsell visível.
|
||||
- **Padrão "invisível"** (Memory, Migrate): nem o ficheiro do módulo existe no build Free — não há
|
||||
card nenhum a mostrar-se, a feature é completamente invisível a um utilizador Free até subir para
|
||||
o build Pro. Só as próprias abilities (também ausentes) e o módulo referenciam estas classes; sem
|
||||
card na Modules tab não há sequer superfície de descoberta da feature no admin.
|
||||
|
||||
---
|
||||
|
||||
## Blueprint para réplica
|
||||
|
||||
### Sistema de módulos (`class-module.php` + `class-modules-registry.php`)
|
||||
|
||||
**Copiar quase verbatim** — é uma pequena máquina de estado limpa (~215 linhas as duas classes
|
||||
juntas), sem lógica Pro nenhuma. O único detalhe de desenho que vale a pena preservar
|
||||
deliberadamente é a **lista `seeded` por-módulo** (em vez de um marcador booleano único) em
|
||||
`apply_defaults()` — é o que permite adicionar um módulo novo numa versão futura sem re-seedar nem
|
||||
tocar no estado que o utilizador já ajustou nos módulos existentes. E o padrão do **helper estático
|
||||
`is_enabled()`** em todo módulo com abilities associadas — necessário porque `wp_abilities_api_init`
|
||||
dispara antes de `init:5`, então o gate das MCP tools nunca pode depender de `register()` já ter
|
||||
corrido.
|
||||
|
||||
### Prompts / Brand Kits (módulos "tab-only")
|
||||
|
||||
Sem lógica nenhuma (`register()` no-op) — só interessam se formos replicar a UI de tabs
|
||||
correspondente. Podem ser omitidos por completo numa primeira réplica sem perda funcional MCP
|
||||
nenhuma.
|
||||
|
||||
### Redirect / Cloud / Themer (módulos-wrapper)
|
||||
|
||||
**Vale a pena adoptar o padrão inteiro**, não só o wrapper: um módulo que arranca a infra-estrutura
|
||||
pesada (store, handler, CPT) em `init:5` E gate as MCP tools associadas via um helper estático
|
||||
separado é o mecanismo real de "kill switch" — desliga tudo (runtime + tools) a partir de UMA
|
||||
option, sem desregistar código morto por todo o lado. O heal-on-upgrade do Themer
|
||||
(`emcp_tools_themer_index_healed`) é um padrão geral a reter: sempre que se corrige um bug de
|
||||
corrupção de dados, adicionar também um marcador de reparação única para sites JÁ afectados —
|
||||
corrigir só o código novo deixa instalações existentes permanentemente partidas.
|
||||
|
||||
### Templates / Agent Skills (módulos "metadata Pro")
|
||||
|
||||
**Ignorar** numa réplica 100% free/open — só fazem sentido se construirmos o nosso próprio sistema
|
||||
de licenciamento/tiering. Se algum dia quisermos essa separação, o padrão exacto a copiar é:
|
||||
`tier()` + `is_available()` a checar um SDK de licença (equivalente Freemius), com a classe de
|
||||
metadata do módulo a viver sempre no build "free" para mostrar um card bloqueado como incentivo de
|
||||
upsell — decisão de UX deliberada, não acidental (o próprio comentário do autor cita este padrão
|
||||
explicitamente ao comparar Agent Skills com Templates).
|
||||
|
||||
### Image Optimization
|
||||
|
||||
**Copiar de perto — é a feature opt-in mais bem desenhada e mais valiosa de toda a árvore Free, sem
|
||||
nenhuma dependência Pro.** Decisões de desenho a preservar:
|
||||
- **Idempotência** via marcador de estado `_emcp_optim` em post meta — evita reprocessar em cada
|
||||
regeneração de metadata.
|
||||
- **Reversibilidade** via espelho de backup em `uploads/emcp-originals/` — nunca destruir sem saída
|
||||
de emergência.
|
||||
- O filtro `emcp_tools_optimize_attachment` como seam de extensibilidade explícito — manter um
|
||||
ponto de opt-out per-attachment para outras ferramentas (sideload/stock-image) é um padrão a
|
||||
reter mesmo fora deste contexto específico.
|
||||
- **REST/CLI sempre-WebP vs frontend condicional ao header `Accept`** é um comportamento subtil mas
|
||||
importante — as MCP tools devem sempre receber o asset optimizado, independentemente da política
|
||||
pública de servir WebP do site.
|
||||
- O processador em lote resumível (cursor em option + batch pequeno, 1-50) é o padrão standard
|
||||
WP-admin-ajax para evitar timeout em bibliotecas grandes — sem surpresas, replicar tal-e-qual.
|
||||
- **Gotcha a preservar**: o clamp de quality (1-100) é partilhado entre `Optimizer` e `Resizer` via
|
||||
método estático único — evitar duplicar essa lógica de clamp em dois sítios.
|
||||
|
||||
### SVG Support
|
||||
|
||||
**Copiar de perto, com cuidado redobrado — é a feature com maior superfície de segurança de toda a
|
||||
árvore Free.** Coisas que NÃO se podem saltar:
|
||||
- **Sanitização fail-closed**: um SVG que não consiga ser limpo tem de ser rejeitado, nunca deixado
|
||||
passar silenciosamente.
|
||||
- A correcção de `wp_check_filetype_and_ext` **não é opcional** — a maioria das implementações
|
||||
ingénuas de "basta adicionar svg a upload_mimes" esquecem-se disto e o upload SVG falha
|
||||
silenciosamente no caminho REST/sideload (comentário explícito do autor: "the piece most SVG
|
||||
plugins miss" — é exactamente o tipo de gotcha não óbvio que vale a pena citar e replicar).
|
||||
- `removeRemoteReferences(true)` explícito — não confiar nos defaults da biblioteca de
|
||||
sanitização, configurar o endurecimento SSRF/XSS deliberadamente.
|
||||
- O autoloader PSR-4 escopado como fallback próprio para a biblioteca vendorizada — um padrão de
|
||||
redundância defensiva a reter sempre que se vendoriza um pacote Composer dentro de um plugin
|
||||
WordPress (geração de classmap Jetpack/Composer é um footgun real e conhecido).
|
||||
|
||||
### Free Brand Kits
|
||||
|
||||
Vale a pena copiar o **padrão** (bundle de um dataset JSON+SVG pequeno e gratuito, partilhando o
|
||||
caminho de escrita/aplicação com qualquer tier pago) mesmo que não cheguemos a construir kits Pro
|
||||
nós próprios — significa que a UX de "aplicar um kit inicial" via admin funciona sem infra-estrutura
|
||||
de licenciamento nenhuma.
|
||||
|
||||
### Inventário Pro (Parte 2)
|
||||
|
||||
Para uma réplica 100% free/aberta, estas 30 classes **simplesmente não se constroem** — representam
|
||||
~30 dos ~165 tools totais (~18%) e estão inteiramente ausentes do que seria preciso reimplementar
|
||||
para um clone só-free. Se algum dia quisermos uma separação de monetização própria, o mecanismo
|
||||
exacto a copiar é: (a) gate `class_exists()` no ponto de chamada do registrar (o ficheiro da classe
|
||||
Pro literalmente não existe a menos que a pasta do plugin pago esteja activa — mesmo modelo de
|
||||
Free/Pro como duas pastas de plugin distintas usado por este vendor, ver `00-ARQUITECTURA.md` §7);
|
||||
(b) um toggle de módulo SECUNDÁRIO e opcional (`is_enabled()`) para o punhado de tools que também
|
||||
querem um interruptor admin independente da licença (Skill/Memory/Migrate); (c) `is_available()` na
|
||||
própria classe de módulo a verificar um SDK de licença, para os módulos "metadata-only" com card
|
||||
bloqueado (Templates/Agent-Skills).
|
||||
|
||||
---
|
||||
|
||||
## Fonte
|
||||
|
||||
Leitura directa (19-08-2026) de:
|
||||
|
||||
- `includes/modules/class-module.php`
|
||||
- `includes/modules/class-modules-registry.php`
|
||||
- `includes/modules/class-prompts-module.php`
|
||||
- `includes/modules/class-brand-kits-module.php`
|
||||
- `includes/modules/class-templates-module.php`
|
||||
- `includes/modules/class-redirect-module.php`
|
||||
- `includes/modules/class-cloud-module.php`
|
||||
- `includes/modules/class-themer-module.php`
|
||||
- `includes/modules/class-agent-skills-module.php`
|
||||
- `includes/modules/image-optimization/class-image-optimization-module.php`
|
||||
- `includes/modules/image-optimization/class-image-optimizer.php`
|
||||
- `includes/modules/image-optimization/class-image-resizer.php`
|
||||
- `includes/modules/image-optimization/class-webp-generator.php`
|
||||
- `includes/modules/image-optimization/class-webp-rewriter.php`
|
||||
- `includes/modules/image-optimization/class-bulk-optimizer.php`
|
||||
- `includes/modules/image-optimization/settings-fields.php`
|
||||
- `includes/modules/svg-support/class-svg-support-module.php`
|
||||
- `includes/modules/svg-support/class-svg-sanitizer.php`
|
||||
- `includes/class-free-brand-kits.php`
|
||||
- `includes/abilities/class-ability-registrar.php` (627 linhas, integral — extracção de todas as
|
||||
chamadas `class_exists()`)
|
||||
|
||||
Listagens de directório completas (equivalente a `ls`), 19-08-2026:
|
||||
- `includes/abilities/` (47 ficheiros + `forms/`, `seo/`)
|
||||
- `includes/abilities/forms/`
|
||||
- `includes/abilities/seo/`
|
||||
- `includes/modules/` (7 módulos + base + registry + `image-optimization/`, `svg-support/`)
|
||||
- `includes/modules/image-optimization/`
|
||||
- `includes/modules/svg-support/`
|
||||
- `includes/themer/` e `includes/themer/php/`
|
||||
- `includes/cloud/`
|
||||
- `includes/` (nível topo)
|
||||
|
||||
Tentativas directas de leitura de ficheiro (equivalente a `test -f`), todas devolvendo "No such
|
||||
file or directory" — confirmação de ausência, 19-08-2026, para: `class-woo-integration.php`,
|
||||
`class-memory-module.php`, `class-migrate-module.php`, `class-skill-abilities.php`,
|
||||
`class-memory-abilities.php`, `class-migrate-abilities.php`, `class-system-kit-abilities.php`,
|
||||
`class-seo-abilities.php`, `class-a11y-abilities.php`, `class-widget-builder-abilities.php`,
|
||||
`class-block-builder-abilities.php`, `class-essential-addons-integration.php`,
|
||||
`class-premium-addons-integration.php`, `class-uae-integration.php`,
|
||||
`class-generatepress-integration.php`, `class-generateblocks-integration.php`,
|
||||
`class-blocksy-blocks-integration.php`, `class-blocksy-extensions-integration.php`,
|
||||
`forms/class-wpforms-integration.php`, `seo/class-yoast-integration.php` (spot-checks
|
||||
representativos das famílias Forms/SEO Pro, cuja ausência integral é também confirmada pela
|
||||
listagem completa dos respectivos subdirectórios).
|
||||
|
||||
Cruzado com `docs/00-ARQUITECTURA.md` (mesma batch, 19-08-2026) e `skill://emcp-tools` (auditoria
|
||||
de postura de segurança, 16-08-2026).
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
# EMCP Tools — Mapeamento completo e blueprint de réplica
|
||||
|
||||
Índice e síntese de 11 documentos (~6 070 linhas), produzidos 19-08-2026 por leitura directa do
|
||||
código-fonte `emcp-tools` v3.12.1 (build Free, `msrbuilds/elementor-mcp`, GPL-2.0-or-later),
|
||||
instalado em `emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`).
|
||||
Cruzado com `skill://emcp-tools` (auditoria de postura de segurança viva nos 3 sites do
|
||||
ecossistema: `descomplicar.pt`, `emanuelalmeida.pt`, `starter.descomplicar.pt`).
|
||||
|
||||
**Objectivo desta série:** especificação funcional completa, ao nível de "o que este código faz e
|
||||
porquê", para servir de base a uma réplica própria — seja fork (GPL permite) seja reescrita limpa.
|
||||
Nenhum documento é resumo de superfície; cada um lê o código-fonte real, cita comentários do autor
|
||||
quando revelam decisões não óbvias, e termina numa secção "Blueprint para réplica" com veredicto
|
||||
copiar-1:1 / simplificar / deixar de fora.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mapa dos documentos
|
||||
|
||||
| Doc | Assunto | Linhas | Abilities (aprox.) | Dependência |
|
||||
|---|---|---|---|---|
|
||||
| [00-ARQUITECTURA](00-ARQUITECTURA.md) | Bootstrap, ability registrar, dispatcher compact mode, `register_mcp_server`, guarda Free⇄Pro | 277 | n/a (estrutural) | — (fundação de tudo) |
|
||||
| [01-ELEMENTOR-CLASSICO](01-ELEMENTOR-CLASSICO.md) | Páginas, layout, widgets (catálogo), templates, global settings/classes, custom code, SVG, composite | 632 | ~35 | Elementor activo |
|
||||
| [02-ATOMIC-V4-GUTENBERG](02-ATOMIC-V4-GUTENBERG.md) | Elementor Atomic v4 (`$$type` props, widgets, flexbox/div-block) + Gutenberg nativo (sempre-on) | 515 | ~25 | Elementor 4.0+ atomic (widgets/layout) · nenhuma (Gutenberg) |
|
||||
| [03-WORDPRESS-CORE-TEMAS](03-WORDPRESS-CORE-TEMAS.md) | Posts/media/settings/plugins/temas/users/menus + integrações de tema (Astra/Spectra/Kadence) | 494 | ~30 | Nenhuma (core puro) |
|
||||
| [04-THEMER](04-THEMER.md) | CPT header/footer/single/archive/search/404, sistema de condições, render pipeline, Themer PHP | 687 | ~13 | Módulo `themer` (free, on por omissão) |
|
||||
| [05-REDIRECTS-SEARCH-LEDGER](05-REDIRECTS-SEARCH-LEDGER.md) | Redirects 301/302, broken-links, **change ledger unificado** (rollback), content-mirror | 727 | ~14 | Módulo `redirects` (redirects) · nenhuma (ledger) |
|
||||
| [06-SANDBOX-CUSTOM-CODE](06-SANDBOX-CUSTOM-CODE.md) | PHP snippets com aprovação humana, export/import de artefactos, Widget Builder (Pro, ausente) | 336 | ~20 | Nenhuma (snippets free) |
|
||||
| [07-SYSTEM-OPS](07-SYSTEM-OPS.md) | Filesystem, base de dados directa, WP-CLI, Security Scanner, Performance Analyzer | 629 | ~15 | Nenhuma — **maior risco do plugin** |
|
||||
| [08-INTEGRACOES-TERCEIROS](08-INTEGRACOES-TERCEIROS.md) | ACF, Meta Box, formulários (CF7 free), SEO (SlimSEO free) | 457 | ~30 (free) | Plugin de terceiros instalado |
|
||||
| [09-STOCK-IMAGES-CLOUD-OAUTH](09-STOCK-IMAGES-CLOUD-OAUTH.md) | Unsplash/Pexels/Pixabay, EMCP Cloud (ligação/sync), servidor OAuth para MCP remoto | 654 | ~17 | Nenhuma |
|
||||
| [10-MODULOS-E-INVENTARIO-PRO](10-MODULOS-E-INVENTARIO-PRO.md) | Sistema de módulos (toggles), Image Optimization, SVG Support, **inventário definitivo das 30 classes Pro-only** | 661 | n/a + 30 ausentes | — (estrutural + auditoria) |
|
||||
|
||||
Total: **~6 070 linhas de documentação** cobrindo **~165 abilities** (número confirmado ao vivo em
|
||||
`skill://emcp-tools` §7) sobre **~128 465 linhas de PHP** do plugin (`~50k` das quais são o SDK
|
||||
Freemius bundled, irrelevante para réplica).
|
||||
|
||||
---
|
||||
|
||||
## 2. O achado mais importante de toda a série
|
||||
|
||||
**A peça MCP não é do fornecedor do plugin — é do próprio WordPress core team.** O EMCP Tools
|
||||
depende de `wordpress/mcp-adapter` + `wordpress/php-mcp-schema`, dois pacotes Composer oficiais
|
||||
`make.wordpress.org/ai`, GPL-2.0-or-later, e da Abilities API nativa do WordPress 6.9+/7.0
|
||||
(`wp_register_ability()`). **Não há protocolo nenhum para reimplementar.** O trabalho real de uma
|
||||
réplica é 100% aplicação: registar as nossas próprias abilities com o nosso prefixo, e chamar
|
||||
`$mcp_adapter->create_server()` com o nosso `server_id`. Ver `00-ARQUITECTURA.md` §0.
|
||||
|
||||
Consequência prática: a pergunta "construir um MCP para WordPress do zero" tem uma resposta muito
|
||||
mais barata do que pareceria — `composer require wordpress/mcp-adapter` dá a máquina toda
|
||||
(JSON-RPC, sessão, transporte HTTP/stdio). O valor real deste plugin (e desta documentação) está
|
||||
no **catálogo de ~165 abilities bem desenhadas** por cima dessa máquina, não na máquina em si.
|
||||
|
||||
---
|
||||
|
||||
## 3. Os 5 subsistemas de maior valor para copiar quase verbatim
|
||||
|
||||
Ordenados por relação esforço-de-reescrita ÷ risco-de-reintroduzir-bugs-já-corrigidos (o critério
|
||||
mais citado em todos os 10 documentos de subsistema):
|
||||
|
||||
1. **`EMCP_Tools_Atomic_Props`** (doc 02, 973 linhas) — o sistema `$$type` do Elementor 4.0+.
|
||||
Sete issues numeradas do próprio autor (#36, #56, #74, #97, #101, #102, #111) já corrigidas;
|
||||
`is_atomic_supported()` usa introspecção de tipos registados, nunca `version_compare()`
|
||||
(o Elementor reporta versão 3.x com atomic já activo como experiment). Reescrever do zero
|
||||
arrisca reproduzir literalmente 2 anos de bugs de produção documentados.
|
||||
2. **O change ledger unificado** (doc 05 §4: `Change_Log` + `Change_Recorder` + `Change_Blobs`) —
|
||||
é o mecanismo de rollback partilhado por **praticamente todos os outros subsistemas**
|
||||
(Elementor, Gutenberg, filesystem, DB, ACF, redirects, posts). Onze tipos de rollback
|
||||
diferentes (`elementor-data`, `file-backup`, `db-before-image`, `post-fields`, `user-create`,
|
||||
etc.), cada um com o seu applier. Construir isto PRIMEIRO numa réplica poupa reimplementar
|
||||
undo ad-hoc em cada subsistema individual — é exactamente o padrão que `08-INTEGRACOES-TERCEIROS.md`
|
||||
recomenda generalizar ainda mais (a base SEO já o faz com zero código extra por integração).
|
||||
3. **`EMCP_Tools_Filesystem_Guard`/`Database_Guard`/`WPCLI_Validator`** (doc 07) — os três guards
|
||||
de maior superfície de segurança do plugin. `resolve_path()` (confinamento a `ABSPATH`,
|
||||
comparação de prefixo correcta), `is_read_only_sql()` (scanner char-a-char anti-ReDoS, com dois
|
||||
bugs de "quase-passou" já documentados nos comentários — o truque `/*!` do MySQL e o regex de
|
||||
`LOAD_FILE(` sem `\b` a fechar), e o tokenizador consciente de aspas do WP-CLI validator.
|
||||
4. **`EMCP_Tools_Block_Tree`** (doc 02, ~330 linhas) — transformações puras sobre
|
||||
`parse_blocks()`/`serialize_blocks()`, endereçamento por PATH de índices. Três guards de
|
||||
segurança em `move()` (auto-referência, alvo-dentro-da-subárvore, correcção de deslocamento de
|
||||
índice) e a reconstrução de `innerContent` em `inner_content_for()` são precisamente o tipo de
|
||||
lógica subtil que se parte silenciosamente ao reescrever sem os mesmos casos de teste.
|
||||
5. **O sistema de condições do Themer** (doc 04 §4) — Schema/Matcher-Registry/Conditions/Context/
|
||||
Resolver com algoritmo de desempate de 3 níveis, mais o índice cacheado (com o bug histórico de
|
||||
ordem save/priority-99 já corrigido e um mecanismo de heal one-time para sites já afectados). É
|
||||
o núcleo mais reutilizável fora do domínio Elementor — qualquer sistema de "mostra X só quando Y"
|
||||
pode reaproveitar a mesma arquitectura.
|
||||
|
||||
---
|
||||
|
||||
## 4. Padrões arquitecturais que atravessam todo o plugin
|
||||
|
||||
Encontrados de forma independente em múltiplos documentos — não são acidente, são a assinatura de
|
||||
um autor disciplinado:
|
||||
|
||||
- **Kill switch por módulo + `is_enabled()` estático** (doc 10 §1.2) — porque
|
||||
`wp_abilities_api_init` corre ANTES de `init:5`, as MCP tools de um módulo nunca podem depender
|
||||
de `$module->is_active()` (a instância só existe depois); todo módulo com abilities associadas
|
||||
expõe um helper estático que lê a option directamente. Ver Redirect/Cloud/Themer/Agent-Skills.
|
||||
- **Construção lazy de motores pesados/frágeis** (doc 07 §4, issue #100) — o Security Scanner só
|
||||
instancia os 4 audits na primeira `scan()` real, nunca no registo da ability (que corre em TODO
|
||||
pedido admin/REST). O mesmo princípio aparece em `register_all()` do registrador global
|
||||
(doc 00 §3), envolto em `try/catch`.
|
||||
- **Argv array, nunca string interpolada** — a regra de ouro para qualquer invocação de processo
|
||||
externo (`WPCLI_Runner::run_shell`, `WPCLI_Jobs::spawn`). Citada explicitamente como "a regra
|
||||
arquitectural mais importante" do doc 07.
|
||||
- **Fragmentação de assinaturas próprias** (doc 07 §4.1) — o scanner de malware fragmenta as
|
||||
suas próprias regras de detecção em tokens concatenados em runtime, para não ser confundido com
|
||||
malware por scanners do host (Imunify360, Wordfence). O achado mais inesperado de toda a série.
|
||||
- **Zero tool MCP de "attach" para código PHP executável** (doc 04 §11) — o Themer PHP tem
|
||||
deliberadamente nenhuma ability que torne um template PHP activo; só um humano no dropdown da
|
||||
metabox pode fazê-lo. Combinado com manifest-only lookup + path containment guard + tamper guard
|
||||
sha256 + shutdown fatal-recovery handler, é o modelo de segurança de 4 camadas mais valioso
|
||||
documentado para "código gerado por IA que corre no servidor".
|
||||
- **Snapshot-before-write como contrato universal, não por-ability** (doc 05 §4.4, doc 08) — o
|
||||
Recorder é uma fachada fina; cada write-site decide o que capturar, mas o mecanismo de
|
||||
persistência/rollback é sempre o mesmo. A base SEO (doc 08) prova que isto pode ser tão genérico
|
||||
a ponto de dar rollback automático a integrações que nunca escrevem uma linha de código de undo.
|
||||
- **Guarda Free⇄Pro single-instance** (doc 00 §7) — Free e Pro são o mesmo código-fonte em duas
|
||||
pastas de plugin; a guarda impede fatal de redeclaração se ambas activarem por engano. Não
|
||||
relevante para réplica (housekeeping de distribuição), mas explica a estrutura de ficheiros.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sequência de construção recomendada para uma réplica
|
||||
|
||||
Ordem por dependência real (não por número de documento) — cada fase assume as anteriores feitas:
|
||||
|
||||
### Fase 0 — Fundação (sem isto, nada mais tem rollback nem é seguro)
|
||||
1. Wrapper de registo (`emcp_tools_register_ability()` equivalente — doc 00 §5): `sanitize()` +
|
||||
`normalize_result()` no mínimo; `strictify()` só se formos alvo de clientes OpenAI-strict.
|
||||
2. `register_mcp_server()` + dispatcher de compact tool mode (doc 00 §3-4) — ~250 linhas triviais,
|
||||
não vale a pena simplificar mais.
|
||||
3. Change ledger unificado (doc 05 §4) — `Change_Log`/`Change_Recorder`/`Change_Blobs`, mesmo que
|
||||
inicialmente só com 2-3 tipos de rollback (`post-fields`, `option`, `file-backup`) e expansível
|
||||
depois.
|
||||
4. Os 3 guards de maior risco (doc 07): `Filesystem_Guard::resolve_path()`,
|
||||
`Database_Guard::is_read_only_sql()`, `WPCLI_Validator`.
|
||||
|
||||
### Fase 1 — Conteúdo WordPress puro (sem dependência de Elementor)
|
||||
5. `03-WORDPRESS-CORE-TEMAS`: posts/media/settings/users/menus/taxonomias. É a base útil mesmo
|
||||
sem nenhuma outra fase — um site sem Elementor já ganha valor MCP real aqui.
|
||||
6. Gutenberg (doc 02 §3 + `Block_Tree`) — também sem dependência de Elementor, sempre-on.
|
||||
|
||||
### Fase 2 — Elementor (o núcleo de "construir páginas")
|
||||
7. `01-ELEMENTOR-CLASSICO`: `Element_Factory`, `Elementor_Data` (com os ~14 gotchas numerados do
|
||||
próprio código — copiar a lógica de save/verify/fallback tal-e-qual), páginas/layout/widgets
|
||||
catalog-backed/templates.
|
||||
8. `02-ATOMIC-V4-GUTENBERG` (parte atomic): `Atomic_Props` (copiar quase inteiro),
|
||||
`Atomic_Widget_Map`, `Atomic_Styles`.
|
||||
|
||||
### Fase 3 — Camadas de produto sobre o conteúdo
|
||||
9. Sistema de módulos (doc 10 §1.1-1.2) — a máquina de toggles + `apply_defaults()` com seeding
|
||||
por-módulo.
|
||||
10. `04-THEMER` — sistema de condições primeiro (é o mais reutilizável), depois CPT/render/blocos.
|
||||
11. `05-REDIRECTS-SEARCH-LEDGER` (parte redirects) — 301/302 + broken-links + shadow warning.
|
||||
|
||||
### Fase 4 — Superfície de risco elevado, opt-in
|
||||
12. `07-SYSTEM-OPS` restante: WP-CLI runner (**simplificável** — se o deployment for sempre
|
||||
SSH+STDIO como este ecossistema, `is_cli_context()` é sempre verdadeiro e todo o caminho shell
|
||||
+ jobs detached pode ser cortado), Security Scanner, Performance Analyzer (ambos activos por
|
||||
omissão, só leitura — bom ROI inicial).
|
||||
13. `06-SANDBOX-CUSTOM-CODE` — PHP snippets com aprovação humana; **replicar o padrão de
|
||||
aprovação, não pular directo para execução automática**.
|
||||
14. Image Optimization + SVG Support (doc 10 §1.4-1.5) — os dois módulos opt-in mais bem
|
||||
desenhados de toda a árvore Free, sem dependência Pro nenhuma.
|
||||
|
||||
### Fase 5 — Integrações e periferia (valor incremental, não bloqueante)
|
||||
15. `08-INTEGRACOES-TERCEIROS` — ACF/Meta Box/CF7/SlimSEO, só se esses plugins fizerem parte do
|
||||
stack alvo.
|
||||
16. `09-STOCK-IMAGES-CLOUD-OAUTH` — Unsplash/Pexels/Pixabay + EMCP Cloud + OAuth (só se for
|
||||
preciso MCP remoto autenticado; Application Password/cookie admin cobre o caso local).
|
||||
|
||||
### Explicitamente fora de âmbito (doc 10 §2)
|
||||
As **30 classes Pro-only** (WooCommerce completo, 8 integrações de formulários adicionais, 6 de
|
||||
SEO adicionais, 2 packs de widgets Elementor de terceiros, Ultimate Addons, Brand/System Kit MCP,
|
||||
SEO/A11y toolkit, Widget/Block Builder MCP, Skill/Memory/Migrate) representam **~18% do catálogo
|
||||
total** e simplesmente não se constroem numa réplica 100% free/aberta — o mecanismo de gate
|
||||
(`class_exists()` + ficheiro fisicamente ausente da árvore) só é replicável se quisermos a mesma
|
||||
separação de monetização.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tabela de risco consolidada
|
||||
|
||||
| Categoria | Activo por omissão? | Nº de tools destrutivas | Mecanismo de defesa principal |
|
||||
|---|---|---|---|
|
||||
| Filesystem (doc 07 §1) | **Não** (write/edit/delete) | 3 | `resolve_path()` confinamento a ABSPATH + backup automático |
|
||||
| Base de dados directa (doc 07 §2) | **Não** (insert/update/delete) | 3 | `is_read_only_sql()` + `valid_table()` contra `SHOW TABLES` + before-image |
|
||||
| WP-CLI (doc 07 §3) | **Não** (as 4, incl. as 2 readonly) | 2 (run/dispatch) | Blocklist comando/subcomando/flag + argv array nunca shell string |
|
||||
| Security Scanner (doc 07 §4) | **Sim** — só leitura | 0 | — |
|
||||
| Performance Analyzer (doc 07 §5) | **Sim** — só leitura | 0 | — |
|
||||
| Themer PHP (doc 04 §11) | Módulo on, mas **zero tool de attach** | 0 (nenhuma torna código executável) | Aprovação humana obrigatória no dropdown da metabox |
|
||||
| Sandbox PHP Snippets (doc 06) | Módulo on, execução gated | 0 (activação sempre humana) | Aprovação humana + manifest hash-verificado |
|
||||
| Elementor/Gutenberg escrita (docs 01-02) | Sim | Várias (delete-*, remove-*) | Change ledger com rollback via `rollback-change` |
|
||||
| Base de dados via ACF/Meta Box (doc 08) | Depende do plugin instalado | Poucas | Snapshot before-image (ACF); **gap identificado**: Meta Box sem registo algum |
|
||||
|
||||
**Gap de segurança mais citado nesta série** (doc 08 §Blueprint): Meta Box não tem nenhum
|
||||
mecanismo de snapshot/rollback, ao contrário de ACF (`record_acf_fields`) e da base SEO
|
||||
(`recordable_meta_keys()` automático). Uma réplica que use o padrão de "declarar meta keys
|
||||
recordáveis" generalizado (em vez de cada integração implementar o seu próprio undo ad-hoc) fecha
|
||||
este gap de origem.
|
||||
|
||||
---
|
||||
|
||||
## 7. Como usar esta documentação
|
||||
|
||||
- **Fork/rebranding rápido**: ler `00-ARQUITECTURA.md` §0 e §7 primeiro (namespace, guarda
|
||||
Free⇄Pro, licenciamento GPL), depois usar os docs 01-10 como mapa de onde cada coisa vive no
|
||||
código real para renomear/remover Freemius/ajustar prefixo de abilities.
|
||||
- **Reescrita limpa**: seguir a sequência da secção 5 acima; cada documento de subsistema tem uma
|
||||
secção "Blueprint para réplica" com veredicto explícito copiar/simplificar/omitir por peça, e uma
|
||||
secção "Fonte" com os ficheiros exactos lidos (para re-verificar contra o código original em
|
||||
caso de dúvida).
|
||||
- **Apenas auditoria/entendimento** (sem construir nada): `skill://emcp-tools` continua a ser a
|
||||
referência operacional para a instalação viva nos 3 sites Descomplicar (deny-list, módulos
|
||||
activos, postura de segurança); esta série documenta o CÓDIGO, não a CONFIGURAÇÃO de nenhum
|
||||
site específico.
|
||||
|
||||
---
|
||||
|
||||
## Metodologia
|
||||
|
||||
Cada um dos 10 documentos de subsistema foi produzido por um subagente `code-explorer`
|
||||
independente, com leitura directa via `ssh://server` dos ficheiros PHP reais da instalação em
|
||||
`emanuelalmeida.pt`, sem overlap de ficheiros entre agentes (coordenado via `hub` IRC quando
|
||||
necessário — ver `history://Doc07SystemOps` para um exemplo de coordenação real). Nenhum conteúdo
|
||||
foi inferido a partir de nomes de classe ou convenções assumidas sem confirmação directa no
|
||||
código-fonte; onde a leitura foi parcial (ex. catálogos Pro de 1000+ linhas), o documento
|
||||
correspondente declara explicitamente o que foi e não foi lido (ver `02-ATOMIC-V4-GUTENBERG.md`
|
||||
§4.5 para o exemplo mais claro desta prática). Revisão cruzada final (secção 2 do processo desta
|
||||
sessão) confirmou consistência de nomes de classe, tipos de rollback, e condições de gating entre
|
||||
todos os 11 documentos — nenhuma contradição encontrada.
|
||||
Reference in New Issue
Block a user