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,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).
|
||||
Reference in New Issue
Block a user