Automatic AI Posts, setup automatizado e seeder de permalinks/avatar

Plugin automatic-ai-posts (v2.0.0)
- Pipeline completo: coleta de feeds RSS/Atom, filtro por IA, reescrita,
  categoria, imagem de capa e agendamento
- Página de configuração com teste real do provedor de IA
- Editor: botão para refazer imagem de capa e metadados, textos em PT-BR
- Ajuda com botão Pix (copia-e-cola + QR) e sem nota de avaliação
- Cache-busting por mtime nos assets do admin

Tema gustavoo-portfolio
- Marca do cabeçalho reduzida a "Gustavo"
- Suporte a título próprio da marca

Setup (docker/wp-init.sh, compose.yaml)
- Plugins de terceiros (Post Views Counter, Rank Math, Site Kit)
  instalados do WordPress.org em vez de versionados
- Falha explícita ao final se algum plugin obrigatório não instalar
- WPCLI_UID/WPCLI_GID configuráveis

Seeder do tema
- Links permanentes "Dia e nome" e avatar "Iniciais" aplicados
  apenas em instalação nova, com regravação do .htaccess
This commit is contained in:
Gustavo committed 2026-11-02 13:33:30 -03:00
1 parent 5891467266
commit 0ea3651fb5
73 files changed
+8858 -39

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
# ─────────────────────────────────────────────────────────────────────────────
# Configuracao de producao — https://gustavoo.me
#
# Uso na VPS:
# cp .env.example .env
# # gere senhas novas: openssl rand -base64 24
# docker compose up -d
#
# O proxy reverso / HTTPS da VPS deve encaminhar para a porta abaixo.
# O WordPress responde em HTTP e o proxy cuida do certificado.
# ─────────────────────────────────────────────────────────────────────────────
# Porta HTTP do WordPress consumida pelo proxy reverso.
WORDPRESS_PORT=8080
# ── Site ─────────────────────────────────────────────────────────────────────
WORDPRESS_SITE_URL=https://gustavoo.me
WORDPRESS_SITE_TITLE=Gustavo Desenvolvedor Full Stack | Web, Mobile, Infraestrutura & Automação
# ── Banco de dados (MariaDB) ─────────────────────────────────────────────────
WORDPRESS_DB_NAME=wordpress
WORDPRESS_DB_USER=wordpress
WORDPRESS_DB_PASSWORD=TROQUE_POR_UMA_SENHA_FORTE
MARIADB_ROOT_PASSWORD=TROQUE_POR_UMA_SENHA_ROOT_FORTE
# ── Administrador criado no primeiro boot ────────────────────────────────────
# Troque a senha depois de instalar. Em uma instalacao existente, nada aqui
# altera usuarios ja criados.
WORDPRESS_ADMIN_USER=admin
WORDPRESS_ADMIN_PASSWORD=TROQUE_POR_UMA_SENHA_FORTE
WORDPRESS_ADMIN_EMAIL=contato@gustavoo.me
# ── Idioma ───────────────────────────────────────────────────────────────────
WP_LOCALE=pt_BR
# ── Plugins / tema ───────────────────────────────────────────────────────────
# Akismet e Hello Dolly (padroes do WordPress) sao removidos.
# Query Monitor e ferramenta de desenvolvimento: mantenha fora de producao.
FORBIDDEN_PATTERN=^akismet$|^hello$|^query-monitor$
# WP_ALLOW_FORBIDDEN_PLUGINS=1 # desativa qualquer remocao
WP_THEME=gustavoo-portfolio
# WP_KEEP_THEMES= # temas extras preservados (separados por espaco)
# ── Plugins instalados automaticamente do WordPress.org ──────────────────────
# Instalados apenas se faltarem e sempre ativados. Ordem: slug do diretorio ou URL.
# post-views-counter -> contador de views (posts + paginas, posicao manual)
# seo-by-rank-math -> Rank Math SEO (sem configuracao, wizard manual)
# google-site-kit -> Site Kit por Google (sem configuracao, conexao manual)
WP_EXTRA_PLUGINS=post-views-counter seo-by-rank-math google-site-kit
# ── Redis object cache ───────────────────────────────────────────────────────
WP_REDIS_HOST=redis
WP_REDIS_PORT=6379
# WP_REDIS_PASSWORD=
# WP_REDIS_DATABASE=0
# ── Atualizacoes automaticas ao reiniciar (modo update) ──────────────────────
# Em producao, atualizar sem revisao pode quebrar o tema. Deixe desativado e
# atualize de proposito: WP_SKIP_UPDATES= (vazio = atualiza)
# Para nunca atualizar automaticamente, descomente:
# WP_SKIP_UPDATES=1
+8
View File
@@ -23,6 +23,14 @@ wp-content/plugins/query-monitor/
wp-content/plugins/hello.php
wp-content/plugins/index.php
# Third-party plugins installed from WordPress.org by docker/wp-init.sh
# (WP_EXTRA_PLUGINS). They are downloaded on first run and updated in place, so
# they are not versioned here: committing them would add ~45 MB of code that the
# setup can fetch on demand.
wp-content/plugins/post-views-counter/
wp-content/plugins/seo-by-rank-math/
wp-content/plugins/google-site-kit/
# Redis object cache drop-in (auto-installed by wpcli service)
wp-content/object-cache.php
+51 -1
View File
@@ -84,9 +84,16 @@ O tema e plugin dependem dos seguintes plugins WordPress (instalados e ativados)
- **FluentCRM** — Automação de marketing e newsletter
```bash
# Subir ambiente Docker
# Criar o arquivo de variáveis de ambiente (obrigatório)
cp .env.example .env
# Edite o .env com suas credenciais (senhas do banco, porta, etc.)
# Subir ambiente Docker (instala e configura tudo automaticamente)
docker compose up -d
# Acompanhar a instalação
docker compose logs -f wpcli
# Acessar WordPress
# http://localhost:8080
# Usuário: admin | Senha: admin (definido no .env)
@@ -102,6 +109,49 @@ docker compose up -d
docker compose logs -f wordpress
```
### O que acontece a cada `docker compose up`
O serviço `wpcli` roda o `docker/wp-init.sh` e detecta o estado do banco:
**Primeira execução (banco vazio) — instalação**
1. `wp core install` com URL/título/admin definidos no `.env`
2. Pacote de idioma `pt_BR`
3. Instala do WordPress.org os plugins de `WP_EXTRA_PLUGINS` (Post Views Counter, Rank Math SEO, Site Kit) — só se faltarem
4. Ativa **todos** os plugins de `wp-content/plugins/` e remove os que o WordPress traz (`akismet`, `hello`)
5. Redis: constantes no `wp-config.php`, plugin, drop-in `object-cache.php` e verificação de conexão
6. Configura o Post Views Counter: conta **posts e páginas**, posição **Manual only**
7. Ativa o tema `gustavoo-portfolio` e remove os temas padrão
8. Executa o seeder do plugin (páginas, formulários, feeds, permalinks, avatar)
Rank Math SEO e Site Kit são apenas instalados e ativados: o wizard e a conexão com o Google são feitos manualmente no painel.
**Execuções seguintes (site já instalado) — atualização**
1. Nada é reinstalado e nenhum dado é apagado
2. Atualiza o **core** e os **plugins ativos** que tiverem versão nova
3. Ativa plugins novos adicionados à pasta `wp-content/plugins/`
4. Reaplica só o que estiver faltando (idioma, Redis, tema, seeder idempotente)
> A configuração do Post Views Counter roda **uma única vez**, na instalação. Se você mudar a posição no painel, ela não é sobrescrita nos próximos boots.
O seeder também aplica, apenas enquanto os valores ainda forem os de instalação nova:
| Ajuste | Valor aplicado |
|---|---|
| Links permanentes | **Dia e nome** — `/ano/mês/dia/slug/` |
| Avatar padrão | **Iniciais** |
Depois de criar permalinks, o `.htaccess` do Apache é regravado na hora — sem isso as URLs novas dariam 404. Se você já tiver escolhido outro permalink ou avatar, sua escolha é mantida.
Tudo o que é do usuário fica em **volumes**, não no container:
| Volume | Conteúdo |
|---|---|
| `db_data` | banco MariaDB — posts, páginas, usuários, opções, formulários |
| `wordpress_data` | core, uploads, plugins e temas instalados |
| `redis_data` | cache de objetos |
`docker compose down` (sem `-v`) preserva tudo; só `down -v` recria do zero. Para pular atualizações numa reinicialização, use `WP_SKIP_UPDATES=1` no `.env`.
## Deploy
O tema está preparado para deploy em ambientes WordPress padrão (VPS, shared hosting, WP Engine, Kinsta, etc.).
+18 -1
View File
@@ -49,7 +49,7 @@ services:
wpcli:
image: wordpress:cli-php8.3
user: "33:33"
user: "${WPCLI_UID:-33}:${WPCLI_GID:-33}"
depends_on:
wordpress:
condition: service_started
@@ -60,6 +60,23 @@ services:
WORDPRESS_DB_NAME: ${WORDPRESS_DB_NAME}
WORDPRESS_DB_USER: ${WORDPRESS_DB_USER}
WORDPRESS_DB_PASSWORD: ${WORDPRESS_DB_PASSWORD}
WORDPRESS_PORT: ${WORDPRESS_PORT:-8080}
WORDPRESS_SITE_URL: ${WORDPRESS_SITE_URL:-http://localhost:${WORDPRESS_PORT:-8080}}
WORDPRESS_SITE_TITLE: ${WORDPRESS_SITE_TITLE:-Gustavo Portfolio}
WORDPRESS_ADMIN_USER: ${WORDPRESS_ADMIN_USER:-admin}
WORDPRESS_ADMIN_PASSWORD: ${WORDPRESS_ADMIN_PASSWORD:-admin}
WORDPRESS_ADMIN_EMAIL: ${WORDPRESS_ADMIN_EMAIL:-admin@example.com}
WP_REDIS_HOST: ${WP_REDIS_HOST:-redis}
WP_REDIS_PORT: ${WP_REDIS_PORT:-6379}
WP_REDIS_PASSWORD: ${WP_REDIS_PASSWORD:-}
WP_REDIS_DATABASE: ${WP_REDIS_DATABASE:-}
WP_EXTRA_PLUGINS: ${WP_EXTRA_PLUGINS:-post-views-counter seo-by-rank-math google-site-kit}
WP_LOCALE: ${WP_LOCALE:-pt_BR}
WP_SKIP_UPDATES: ${WP_SKIP_UPDATES:-}
WP_THEME: ${WP_THEME:-gustavoo-portfolio}
WP_KEEP_THEMES: ${WP_KEEP_THEMES:-}
FORBIDDEN_PATTERN: ${FORBIDDEN_PATTERN:-}
WP_ALLOW_FORBIDDEN_PLUGINS: ${WP_ALLOW_FORBIDDEN_PLUGINS:-}
HOME: /tmp
volumes:
- wordpress_data:/var/www/html
Regular → Executable
+364 -34
View File
@@ -1,49 +1,379 @@
#!/bin/sh
set -e
echo "[wpcli] Waiting for WordPress core install..."
until wp core is-installed --path=/var/www/html 2>/dev/null; do
sleep 3
# Two modes:
# install — the database has no WordPress yet: core, language, plugins,
# theme and the project seeder are set up from scratch.
# update — the site already exists: only the WordPress core and the
# installed plugins are updated. Content, options, widgets,
# users and manually configured settings are never touched.
#
# Whatever a plugin/theme adds on disk (uploads, new plugin folders, edited
# files) survives, because state lives in the db_data and wordpress_data
# volumes, not in this script.
WP_PATH=/var/www/html
SITE_URL="${WORDPRESS_SITE_URL:-http://localhost:${WORDPRESS_PORT:-8080}}"
ADMIN_USER="${WORDPRESS_ADMIN_USER:-admin}"
ADMIN_PASSWORD="${WORDPRESS_ADMIN_PASSWORD:-admin}"
ADMIN_EMAIL="${WORDPRESS_ADMIN_EMAIL:-admin@example.com}"
ADMIN_TITLE="${WORDPRESS_SITE_TITLE:-Gustavo Portfolio}"
WP_THEME="${WP_THEME:-gustavoo-portfolio}"
WP_LOCALE="${WP_LOCALE:-pt_BR}"
# Plugins from the WordPress.org directory that must exist even when they are
# not committed to wp-content/plugins. Installed only when missing, then
# activated. Format: slug (or URL).
WP_EXTRA_PLUGINS="${WP_EXTRA_PLUGINS:-post-views-counter seo-by-rank-math google-site-kit}"
wp_run() {
wp --path="$WP_PATH" "$@"
}
log() {
echo "[wpcli] $*"
}
warn() {
echo "[wpcli] WARN: $*" >&2
}
# ─── Wait for dependencies ───────────────────────────────────────────────────
log "Waiting for database..."
until wp_run db check >/dev/null 2>&1; do
sleep 2
done
if ! wp language core is-installed pt_BR --path=/var/www/html 2>/dev/null; then
echo "[wpcli] Installing pt_BR language pack..."
wp language core install pt_BR --path=/var/www/html
log "Waiting for WordPress core files..."
until [ -f "$WP_PATH/wp-load.php" ]; do
sleep 2
done
# The wp:cli image runs as a different uid than the wordpress image. Grant
# write access to whoever needs it without changing the volume ownership:
# the core folder (plugins, themes, wp-config.php) and wp-content itself.
# Bind-mounted wp-content/plugins|themes come from the host, so their
# permissions are checked too.
if [ ! -w "$WP_PATH/wp-content" ]; then
log "$WP_PATH/wp-content not writable by uid $(id -u), fixing permissions..."
find "$WP_PATH" -maxdepth 1 -exec chmod g+rwX {} + || true
find "$WP_PATH/wp-content" -maxdepth 1 -exec chmod g+rwX {} + || true
mkdir -p "$WP_PATH/wp-content/uploads" 2>/dev/null || true
find "$WP_PATH/wp-content/uploads" -maxdepth 1 -exec chmod g+rwX {} + 2>/dev/null || true
fi
echo "[wpcli] Activating pt_BR site language..."
wp language core activate pt_BR --path=/var/www/html
# Plugins are installed by wp-cli inside the bind-mounted folder, so wp-cli
# must be able to write there. The compose default (user 33:33) matches the
# wordpress image; a host-owned folder is reported instead of failing silently.
for dir in "$WP_PATH/wp-content/plugins" "$WP_PATH/wp-content/themes"; do
[ -d "$dir" ] || continue
if [ ! -w "$dir" ]; then
log "$dir is owned by another user — adjusting group permissions..."
find "$dir" -maxdepth 0 -exec chmod g+rwX {} + 2>/dev/null || true
fi
if [ ! -w "$dir" ]; then
warn "cannot write to $dir as uid $(id -u)/gid $(id -g). Plugin install/update will fail."
warn "fix with: sudo chmod -R g+rwX wp-content/plugins wp-content/themes && sudo chgrp $(id -gn) wp-content/plugins wp-content/themes"
fi
done
echo "[wpcli] Setting Redis connection constants..."
wp config set WP_REDIS_HOST redis --type=constant --path=/var/www/html
wp config set WP_REDIS_PORT 6379 --type=constant --raw --path=/var/www/html
if ! wp plugin is-installed redis-cache --path=/var/www/html; then
echo "[wpcli] Installing Redis Object Cache plugin..."
wp plugin install redis-cache --path=/var/www/html
if [ ! -w "$WP_PATH" ]; then
echo "[wpcli] ERROR: $WP_PATH is not writable by uid $(id -u). Aborting." >&2
exit 1
fi
echo "[wpcli] Activating Redis Object Cache plugin..."
wp plugin activate redis-cache --path=/var/www/html
echo "[wpcli] Enabling Redis object cache..."
wp redis enable --path=/var/www/html || wp redis update-dropin --path=/var/www/html
echo "[wpcli] Redis setup complete."
if ! wp plugin is-installed query-monitor --path=/var/www/html; then
echo "[wpcli] Installing Query Monitor plugin..."
wp plugin install query-monitor --path=/var/www/html
# ─── Mode detection ──────────────────────────────────────────────────────────
if wp_run core is-installed 2>/dev/null; then
MODE=update
else
MODE=install
fi
echo "[wpcli] Activating Query Monitor plugin..."
wp plugin activate query-monitor --path=/var/www/html
# ═════════════════════════════════════════════════════════════════════════════
# INSTALL — first run on an empty database
# ═════════════════════════════════════════════════════════════════════════════
if [ "$MODE" = install ]; then
log "No WordPress installation found — starting first-time setup."
echo "[wpcli] Activating Gustavo Portfolio Core dependencies..."
wp plugin activate fluentform fluent-crm fluent-smtp --path=/var/www/html
log "Installing WordPress core at $SITE_URL..."
wp_run core install \
--url="$SITE_URL" \
--title="$ADMIN_TITLE" \
--admin_user="$ADMIN_USER" \
--admin_password="$ADMIN_PASSWORD" \
--admin_email="$ADMIN_EMAIL" \
--skip-email
echo "[wpcli] Activating Gustavo Portfolio Core plugin..."
wp plugin activate gustavoo-portfolio-core --path=/var/www/html
log "Setting site URL to $SITE_URL..."
wp_run option update home "$SITE_URL"
wp_run option update siteurl "$SITE_URL"
# ═════════════════════════════════════════════════════════════════════════════
# UPDATE — the site already exists, keep every piece of data
# ═════════════════════════════════════════════════════════════════════════════
else
log "Existing installation detected — keeping database, content and settings."
fi
echo "[wpcli] Activating Gustavo Portfolio theme..."
wp theme activate gustavoo-portfolio --path=/var/www/html
# ─── Language (both modes) ───────────────────────────────────────────────────
if ! wp_run language core is-installed "$WP_LOCALE" 2>/dev/null; then
log "Installing $WP_LOCALE language pack..."
wp_run language core install "$WP_LOCALE"
fi
if [ "$(wp_run option get WPLANG 2>/dev/null || true)" != "$WP_LOCALE" ]; then
log "Activating $WP_LOCALE site language..."
wp_run language core activate "$WP_LOCALE"
fi
# ─── Redis constants (both modes) ────────────────────────────────────────────
# Values from .env override wp-config.php; anything empty keeps the current
# value, so a manual edit is never lost.
REDIS_HOST="${WP_REDIS_HOST:-}"
REDIS_PORT="${WP_REDIS_PORT:-}"
REDIS_PASSWORD="${WP_REDIS_PASSWORD:-}"
REDIS_DATABASE="${WP_REDIS_DATABASE:-}"
if [ -n "$REDIS_HOST" ]; then
wp_run config set WP_REDIS_HOST "$REDIS_HOST" --type=constant >/dev/null
fi
if [ -n "$REDIS_PORT" ]; then
wp_run config set WP_REDIS_PORT "$REDIS_PORT" --type=constant --raw >/dev/null
fi
if [ -n "$REDIS_PASSWORD" ]; then
wp_run config set WP_REDIS_PASSWORD "$REDIS_PASSWORD" --type=constant >/dev/null
fi
if [ -n "$REDIS_DATABASE" ]; then
wp_run config set WP_REDIS_DATABASE "$REDIS_DATABASE" --type=constant --raw >/dev/null
fi
# ═════════════════════════════════════════════════════════════════════════════
# UPDATES — only on an existing installation, only when an update exists.
# Uses the WordPress upgrader so database migrations run as on the dashboard.
# ═════════════════════════════════════════════════════════════════════════════
update_core() {
UPDATE_JSON="$(wp_run core check-update --format=json 2>/dev/null || echo '[]')"
case "$UPDATE_JSON" in
''|'[]'|'null')
log "WordPress core is up to date ($(wp_run core version 2>/dev/null))."
return 0
;;
esac
# no --minor: security/latest release is the desired behaviour, and the
# major upgrade keeps content and database intact through wp-cli.
log "New WordPress version available — updating core..."
wp_run core update || warn "core update failed"
}
update_plugins() {
# Only plugins that exist locally are updated; nothing new is downloaded.
STALE="$(wp_run plugin list --status=active --fields=name,update --format=csv 2>/dev/null \
| awk -F, 'NR>1 && $2 != "" && $2 != "none" && $2 != "available" {print $1}')"
if [ -z "$STALE" ]; then
log "All active plugins are up to date."
return 0
fi
for plugin in $STALE; do
log "Updating plugin '$plugin'..."
wp_run plugin update "$plugin" || warn "could not update plugin '$plugin'"
done
}
if [ "$MODE" = update ] && [ -z "${WP_SKIP_UPDATES:-}" ]; then
update_core
update_plugins
fi
# ─── Extra plugins from WordPress.org (both modes) ───────────────────────────
# These plugins are not versioned in Git: the setup installs them from the
# WordPress.org directory so the repository stays small. Only installed when
# missing: an update is handled by the update step, so a local version or
# manual change is never overwritten here.
INSTALLED_ANY=0
FAILED_PLUGINS=""
for plugin in $WP_EXTRA_PLUGINS; do
if wp_run plugin is-installed "$plugin" 2>/dev/null; then
continue
fi
log "Installing plugin '$plugin' from WordPress.org..."
if wp_run plugin install "$plugin"; then
INSTALLED_ANY=1
else
warn "could not install plugin '$plugin'"
FAILED_PLUGINS="$FAILED_PLUGINS $plugin"
fi
done
# ─── Plugins (both modes) ────────────────────────────────────────────────────
# Every plugin in the bind-mounted wp-content/plugins folder is the desired set:
# new folders are activated, missing ones are never downloaded. Plugins bundled
# with WordPress that are not part of the project are removed.
FORBIDDEN_PATTERN="${FORBIDDEN_PATTERN:-^akismet$|^hello$}"
if [ -n "${WP_ALLOW_FORBIDDEN_PLUGINS:-}" ]; then
FORBIDDEN_PATTERN=""
fi
# Drop-ins (db.php, object-cache.php, ...) live in wp-content and are managed
# by their parent plugin, they are never activated directly.
is_dropin() {
wp_run plugin list --field=name --status=dropin 2>/dev/null | grep -qx "$1"
}
log "Activating plugins found in $WP_PATH/wp-content/plugins..."
for plugin in $(wp_run plugin list --field=name --skip-plugins --skip-themes 2>/dev/null || wp_run plugin list --field=name); do
if [ -n "$FORBIDDEN_PATTERN" ] && printf '%s' "$plugin" | grep -Eq "$FORBIDDEN_PATTERN"; then
continue
fi
if is_dropin "$plugin"; then
continue
fi
if ! wp_run plugin is-active "$plugin" 2>/dev/null; then
log "Activating plugin '$plugin'..."
wp_run plugin activate "$plugin" || warn "could not activate '$plugin'"
fi
done
for plugin in $(wp_run plugin list --field=name --skip-plugins --skip-themes 2>/dev/null || wp_run plugin list --field=name); do
if [ -z "$FORBIDDEN_PATTERN" ]; then
break
fi
if printf '%s' "$plugin" | grep -Eq "$FORBIDDEN_PATTERN"; then
log "Removing unwanted plugin '$plugin'..."
wp_run plugin deactivate "$plugin" 2>/dev/null || true
wp_run plugin uninstall "$plugin" 2>/dev/null || true
wp_run plugin delete "$plugin" 2>/dev/null || true
fi
done
# ─── Post Views Counter (first install only) ─────────────────────────────────
# Counts views for posts and pages, with manual placement so the theme decides
# where the counter appears (shortcode [post-views]). Existing settings are
# never changed: this only runs on a fresh installation.
if [ "$MODE" = install ] && wp_run plugin is-active post-views-counter 2>/dev/null; then
log "Configuring Post Views Counter (posts + pages, manual placement)..."
wp_run eval '
$general = get_option( "post_views_counter_settings_general", array() );
if ( ! is_array( $general ) ) {
$general = array();
}
$types = isset( $general["post_types_count"] ) && is_array( $general["post_types_count"] )
? $general["post_types_count"]
: array( "post" );
foreach ( array( "post", "page" ) as $type ) {
if ( ! in_array( $type, $types, true ) ) {
$types[] = $type;
}
}
$general["post_types_count"] = array_values( $types );
update_option( "post_views_counter_settings_general", $general );
$display = get_option( "post_views_counter_settings_display", array() );
if ( ! is_array( $display ) ) {
$display = array();
}
$display["position"] = "manual";
update_option( "post_views_counter_settings_display", $display );
$pvc = Post_Views_Counter();
if ( isset( $pvc->options ) ) {
$pvc->options["general"]["post_types_count"] = $general["post_types_count"];
$pvc->options["display"]["position"] = "manual";
}
echo "Post Views Counter: post_types=[post,page] position=[manual]" . PHP_EOL;
' || warn "could not configure Post Views Counter"
fi
# Rank Math SEO and Site Kit are installed and activated only; no wizard, SEO
# settings, Search Console or Analytics connection is configured here.
# ─── Redis object cache (both modes) ─────────────────────────────────────────
if ! wp_run plugin is-installed redis-cache 2>/dev/null; then
log "Installing Redis Object Cache plugin..."
wp_run plugin install redis-cache
fi
if ! wp_run plugin is-active redis-cache 2>/dev/null; then
log "Activating Redis Object Cache plugin..."
wp_run plugin activate redis-cache || warn "could not activate redis-cache"
fi
# The drop-in (wp-content/object-cache.php) is what actually routes the cache to
# Redis; it is (re)created when missing, stale or disabled.
if ! wp_run redis status 2>/dev/null | grep -qE '^[[:space:]]*Drop-in:[[:space:]]+Valid'; then
log "Enabling Redis object cache drop-in..."
wp_run redis enable >/dev/null 2>&1 || wp_run redis update-dropin >/dev/null 2>&1 || true
fi
if [ -n "$REDIS_HOST" ]; then
REDIS_STATUS="$(wp_run redis status 2>/dev/null || true)"
# The status output may lead with the "Drop-ins: [...]" block, so the checks
# match the key line anywhere in the output.
if printf '%s' "$REDIS_STATUS" | grep -qE '^[[:space:]]*Status:[[:space:]]+Connected'; then
log "Redis object cache connected."
else
warn "Redis object cache could not connect — check the redis service."
fi
if printf '%s' "$REDIS_STATUS" | grep -qE '^[[:space:]]*Drop-in:[[:space:]]+Valid'; then
log "Redis drop-in (wp-content/object-cache.php) is valid."
else
warn "Redis drop-in is missing or invalid — re-run 'docker compose restart wpcli'."
fi
fi
# ─── Theme (both modes) ──────────────────────────────────────────────────────
if ! wp_run theme is-installed "$WP_THEME" 2>/dev/null; then
warn "theme '$WP_THEME' is not installed in wp-content/themes — skipping activation."
else
if ! wp_run theme is-active "$WP_THEME" 2>/dev/null; then
log "Activating $WP_THEME theme..."
wp_run theme activate "$WP_THEME" || warn "could not activate theme '$WP_THEME'"
fi
fi
# Only project themes stay available: every other theme (the WordPress defaults)
# is removed. WP_KEEP_THEMES lists themes to preserve on purpose.
log "Removing leftover themes..."
for theme in $(wp_run theme list --field=name 2>/dev/null); do
if [ "$theme" = "$WP_THEME" ]; then
continue
fi
if [ -n "${WP_KEEP_THEMES:-}" ] && printf '%s' " $WP_KEEP_THEMES " | grep -q " $theme "; then
continue
fi
if wp_run theme is-active "$theme" 2>/dev/null; then
continue
fi
log "Removing theme '$theme'..."
wp_run theme delete "$theme" || warn "could not delete theme '$theme'"
done
# ─── Project seeder (both modes, idempotent) ─────────────────────────────────
# Only fills in what is missing (pages, forms, feed, widget defaults). Content
# edited by the administrator is never overwritten.
if wp_run plugin is-active gustavoo-portfolio-core 2>/dev/null; then
log "Running portfolio seeder..."
wp_run eval 'GSO_Seeder::maybe_seed();' || warn "seeder failed"
log "Seeder state: $(wp_run option get gso_portfolio_seed_state --format=json 2>/dev/null || echo '{}')"
fi
log "Setup complete ($MODE). Site available at $SITE_URL"
# A plugin that could not be installed leaves the site without a feature the
# project expects (SEO, analytics, view counts). Fail loudly instead of
# reporting success: the container is restarted by the operator and the next
# run simply tries again, since installation is idempotent.
if [ -n "$FAILED_PLUGINS" ]; then
warn "Required plugins are missing:$FAILED_PLUGINS"
warn "Check network access to downloads.wordpress.org and restart the container."
exit 1
fi
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,802 @@
jQuery(function ($) {
if (typeof aapData === 'undefined') return;
var ajaxurl = aapData.ajaxurl;
var s = aapData.s; // translated strings
var $doc = $(document);
var $form = $('#aap-settings-form');
var $exec = $('#rap-auto-interval');
// O <form> é fechado logo após o grid de configurações (para que os botões de
// ação dos outros painéis não o enviem). Por isso os campos do formulário são
// mantidos em sincronia com os valores únicos exibidos na aba Execuções:
// - intervalo do automático: preenchido a partir do valor salvo
// - categorias GNews: os checkboxes só existem no formulário principal
function mirrorFromSaved() {
if (!$exec.length) return;
$exec.val(aapData.interval_value);
$('#rap-auto-unit').val(aapData.interval_unit);
}
function mirrorToForm() {
if (!$exec.length) return;
$('input[name="aap_auto_interval_value"]').val($exec.val());
$('select[name="aap_auto_interval_unit"]').val($('#rap-auto-unit').val());
}
// O destaque dos botões de categoria é controlado pela rolagem (scroll spy)
// ── Doação Pix: copiar a chave ──────────────────────────────────────────
$doc.on('click', '#aap-pix-btn', function () {
var key = String($(this).data('pix') || '');
if (!key) return;
var $feedback = $('#aap-pix-feedback');
function done() {
$feedback.removeClass('is-error').addClass('is-success').text(s.pix_copied || 'OK');
setTimeout(function () {
$feedback.text('').removeClass('is-success');
}, 4000);
}
function fallback() {
var $tmp = $('<textarea>').val(key).css({ position: 'fixed', opacity: 0 }).appendTo('body');
$tmp[0].select();
try { document.execCommand('copy'); done(); } catch (e) {}
$tmp.remove();
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(key).then(done).catch(fallback);
} else {
fallback();
}
});
// ── Teste da conexão com a IA ───────────────────────────────────────────
$doc.on('click', '#aap-test-ai-btn', function () {
var $btn = $(this);
var $feedback = $('#aap-test-ai-feedback');
var i18n = aapData.ai_test || {};
if ($btn.data('busy')) return;
$btn.data('busy', true).prop('disabled', true);
$feedback.removeClass('is-error is-success').addClass('is-pending')
.text(i18n.testing || '...');
$.post(aapData.ajaxurl, {
action: 'aap_test_ai',
nonce: $btn.data('nonce')
})
.done(function (res) {
var data = (res && res.data) || {};
if (res && res.success) {
$feedback.removeClass('is-pending').addClass('is-success').text(
(i18n.ok || 'OK') + ' — ' + data.provider
+ (data.model ? ' · ' + data.model : '')
+ (data.ms ? ' (' + data.ms + ' ms)' : '')
);
} else {
$feedback.removeClass('is-pending').addClass('is-error')
.text(data.error || i18n.error || 'Error.');
}
})
.fail(function () {
$feedback.removeClass('is-pending').addClass('is-error')
.text(i18n.error || 'Error.');
})
.always(function () {
$btn.data('busy', false).prop('disabled', false);
});
});
// ── Provedor de IA: mostra apenas os campos do provedor ativo ───────────
(function providerFields() {
var $select = $('#aap_ai_provider');
if (!$select.length) return;
var defaults = aapData.provider_defaults || {};
function refresh() {
var id = $select.val();
$('.aap-provider-block').each(function () {
var $block = $(this);
$block.prop('hidden', String($block.data('provider')) !== String(id));
});
var $model = $('#aap_model');
if ($model.length && defaults[id]) {
$model.attr('placeholder', defaults[id]);
var known = $.map(defaults, function (value) { return value; });
var value = $model.val();
if (value === '' || $.inArray(value, known) !== -1) {
$model.val(defaults[id]);
}
}
}
$select.on('change', refresh);
refresh();
})();
// ── Alterações não salvas: barra flutuante + descartar ──────────────────
(function unsavedChanges() {
if (!$form.length) return;
var $float = $('#aap-save-float');
var $discard = $('#aap-discard-all');
if (!$float.length) return;
var fields = function () {
return $form.find('input, select, textarea').filter(':enabled');
};
// Fotografia do estado inicial de cada campo
function snapshot() {
var snap = {};
fields().each(function () {
var el = this;
var key = el.name || el.id;
if (!key) return;
if (el.type === 'checkbox' || el.type === 'radio') {
snap[key + '::' + (el.value || '')] = el.checked;
} else {
snap[key] = el.value;
}
});
return snap;
}
var initial = snapshot();
function isDirty() {
var now = snapshot();
var k;
for (k in now) {
if (now[k] !== initial[k]) return true;
}
for (k in initial) {
if (!(k in now)) return true;
}
return false;
}
function refresh() {
$float.prop('hidden', !isDirty());
}
// Desfaz tudo o que foi digitado desde o carregamento
function restore() {
fields().each(function () {
var el = this;
var key = el.name || el.id;
if (!key) return;
if (el.type === 'checkbox' || el.type === 'radio') {
var k = key + '::' + (el.value || '');
if (k in initial) el.checked = initial[k];
} else if (key in initial) {
el.value = initial[key];
}
});
}
$form.on('input change', 'input, select, textarea', refresh);
$discard.on('click', function () {
restore();
refresh();
// Recoloca o foco no início do conteúdo para o usuário ver o estado original
var body = document.querySelector('#aap-app .aap-panel:not([hidden]) .aap-panel-body');
if (body) body.scrollTo({ top: 0, behavior: 'smooth' });
});
refresh();
})();
// ── Salva configurações antes de executar ────────────────────────────────
function saveSettings() {
mirrorToForm();
if (!$form.length) return $.Deferred().resolve().promise();
return $.ajax({
url: aapData.options_url,
type: 'POST',
data: $form.serialize(),
});
}
function feedback($el, text, type) {
$el.removeClass('is-error is-success is-warn is-pending').addClass(type ? 'is-' + type : '').html(text);
}
// ── Links de categoria (âncoras) ────────────────────────────────────────
$doc.on('click', '#aap-app .aap-cat-links a', function (e) {
var $card = $($(this).attr('href'));
if (!$card.length) return;
e.preventDefault();
// Rola até o card
// No layout de aplicação o conteúdo rola dentro do painel; fora dele,
// a página inteira é que rola.
var body = document.querySelector('#aap-app .aap-panel:not([hidden]) .aap-panel-body');
if (body && body.scrollHeight > body.clientHeight + 2) {
var delta = $card[0].getBoundingClientRect().top - body.getBoundingClientRect().top;
body.scrollTo({ top: Math.max(0, body.scrollTop + delta - 16), behavior: 'smooth' });
} else {
var top = $card[0].getBoundingClientRect().top + window.pageYOffset - aapHeadHeight() - 16;
window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
$('#aap-app .aap-cat-links a').removeClass('is-active');
$(this).addClass('is-active');
$card.addClass('aap-flash');
setTimeout(function () { $card.removeClass('aap-flash'); }, 1400);
});
// ── Destaque automático do botão da seção visível (scroll spy) ──────────
(function catScrollSpy() {
var links = Array.prototype.slice.call(
document.querySelectorAll('#aap-app .aap-cat-links a')
);
if (!links.length) return;
var body = document.querySelector('#aap-app .aap-panel:not([hidden]) .aap-panel-body');
var head = document.querySelector('#aap-app .aap-sticky-head');
function update() {
// Linha de referência: logo abaixo do cabeçalho visível
var threshold = (head ? head.getBoundingClientRect().bottom : 0) + 24;
var current = links[0];
links.forEach(function (link) {
var card = document.getElementById(link.getAttribute('data-cat'));
if (!card) return;
if (card.getBoundingClientRect().top <= threshold) current = link;
});
// A última seção costuma ser curta e nunca chega ao topo:
// no fim da rolagem ela é a ativa.
var atBottom;
if (body && body.scrollHeight > body.clientHeight + 2) {
atBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 2;
} else {
atBottom = window.innerHeight + window.pageYOffset >=
document.documentElement.scrollHeight - 2;
}
if (atBottom) current = links[links.length - 1];
links.forEach(function (link) {
link.classList.toggle('is-active', link === current);
});
}
// Evita recalcular a cada pixel: no máximo um cálculo por quadro
var ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(function () {
ticking = false;
update();
});
}
// Rola dentro do painel (desktop) e na janela (mobile)
if (body) body.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
update();
})();
// ── Cabeçalho fixo de cada aba ─────────────────────────────────────────
function adminBarHeight() {
var bar = document.getElementById('wpadminbar');
if (!bar) return 0;
var r = bar.getBoundingClientRect();
return r.bottom > 0 ? Math.round(r.height) : 0; // 32px (desktop) / 46px (mobile)
}
// Altura total ocupada no topo = barra do WordPress (quando visível) + header
function aapHeadHeight() {
var head = document.querySelector('#aap-app .aap-sticky-head');
return head ? Math.round(head.getBoundingClientRect().height) + adminBarHeight() : 0;
}
(function stickyHead() {
var head = document.querySelector('#aap-app .aap-sticky-head');
var side = document.querySelector('#aap-app .aap-sidebar-inner');
if (!head && !side) return;
function update() {
var barH = adminBarHeight();
var foot = document.getElementById('wpfooter');
var footH = (foot && getComputedStyle(foot).display !== 'none')
? Math.round(foot.getBoundingClientRect().height) : 0;
// Altura do rodapé usada no layout do painel (desktop)
document.documentElement.style.setProperty('--aap-footer-h', footH + 'px');
// Cola exatamente abaixo da barra do WordPress; se ela sair da tela, cola no topo
if (head) {
head.style.top = barH + 'px';
head.classList.toggle('is-stuck', window.pageYOffset > 0);
document.documentElement.style.setProperty('--aap-head-h', aapHeadHeight() + 'px');
}
// A sidebar fica fixa: sai do fluxo da página e não é afetada pelo fim
// da coluna (era isso que fazia os itens "subirem" no fim da rolagem).
if (side) {
if (window.matchMedia('(max-width: 782px)').matches) {
// No mobile ela é empilhada no topo, sem posicionamento fixo
clearSide();
} else {
var colRect = side.parentElement.getBoundingClientRect();
// No topo da página o app começa abaixo da barra (e de avisos);
// ao rolar ele encosta na barra e ali permanece.
var top = Math.max(barH, Math.round(colRect.top));
var h = Math.max(200, Math.round(window.innerHeight - top - footH));
setSide('position', 'fixed');
setSide('left', Math.round(colRect.left) + 'px');
setSide('width', Math.round(colRect.width) + 'px');
setSide('top', top + 'px');
setSide('height', h + 'px');
}
}
}
// Escreve no estilo só quando o valor muda, evitando recálculos por quadro
var last = {};
function setSide(prop, value) {
if (last[prop] === value) return;
last[prop] = value;
side.style[prop] = value;
}
function clearSide() {
['position', 'left', 'width', 'top', 'height'].forEach(function (p) {
if (last[p] !== '') { last[p] = ''; side.style[p] = ''; }
});
}
update();
window.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update, { passive: true });
})();
// ── Preferências de log (compartilhadas entre configurações e aba log) ──
function pref(key, value) {
try {
if (value === undefined) return localStorage.getItem('aap_' + key) !== '0';
localStorage.setItem('aap_' + key, value ? '1' : '0');
} catch (err) {}
return value;
}
$doc.on('change', '.aap-pref-autoscroll', function () {
var on = this.checked;
$('.aap-pref-autoscroll').prop('checked', on);
pref('autoscroll', on);
});
$doc.on('change', '.aap-pref-live', function () {
var on = this.checked;
$('.aap-pref-live').prop('checked', on);
pref('live', on);
if (on) startLogPolling(); else stopLogPolling();
});
// ── Mensagem de "configurações salvas" ──────────────────────────────────
if (/[?&](aap-saved=1|settings-updated=true)/.test(window.location.search)) {
var $toast = $('#aap-save-toast');
var $saveFb = $('#aap-save-feedback');
if ($toast.length && $saveFb.length) {
$saveFb.text(s.settings_saved);
$toast.prop('hidden', false);
setTimeout(function () { $toast.prop('hidden', true); }, 5000);
// Limpa a marca da URL para não repetir o aviso ao recarregar
if (window.history && window.history.replaceState) {
var clean = window.location.href
.replace(/([?&])aap-saved=1&?/, '$1')
.replace(/([?&])settings-updated=true&?/, '$1')
.replace(/[?&]$/, '');
window.history.replaceState({}, '', clean);
}
}
}
// ── Estado de execução ──────────────────────────────────────────────────
var $execSection = $('#rap-exec-section');
var $execText = $('#rap-exec-text');
var $execSub = $('#rap-exec-sub');
var $progress = $('#rap-progress');
var $runStatus = $('#rap-run-status');
var $abortBtn = $('#rap-abort-btn');
var $abortStatus = $('#rap-abort-status');
var $lastRun = $('#rap-last-run');
function setBadge(processing, abortRequested) {
if ($execSection.length) {
$execSection.removeClass('is-idle is-aborting');
if (processing) {
$execSection.toggleClass('is-aborting', !!abortRequested);
$progress.toggleClass('is-indeterminate', true);
$execText.text(abortRequested ? s.aborting : s.running);
if (aapData.started_label) $execSub.text(aapData.started_label);
} else {
$execSection.addClass('is-idle');
$progress.removeClass('is-indeterminate');
$execText.text(s.no_execution);
$execSub.text(idleDesc);
}
if (processing) {
$abortBtn.prop('disabled', false).show();
} else {
$abortBtn.hide();
$abortStatus.text('');
if (($runStatus.text() || '') === s.processing_feeds) $runStatus.text('');
}
}
}
// Subtítulo do estado ocioso (vem pronto do servidor)
var idleDesc = aapData.idle_desc || $execSub.text();
// ── Botão "Executar agora" ──────────────────────────────────────────────
var $runBtn = $('#rap-run-now');
function setRunBtn(running) {
$runBtn.prop('disabled', running);
var label = running ? s.running : s.run_now;
var $lbl = $runBtn.find('span');
if ($lbl.length) $lbl.text(label); else $runBtn.text(label);
$runBtn.toggleClass('is-busy', running);
}
function showRunError(msg) {
feedback($runStatus, s.error_prefix + ' ' + msg, 'error');
}
// ── Polling permanente — o servidor é a fonte da verdade ────────────────
var serverProcessing = aapData.processing;
var consecutiveErrors = 0;
var wasProcessing = serverProcessing;
setBadge(serverProcessing, false);
setRunBtn(serverProcessing);
function handleAjaxError(xhr) {
if (xhr.status === 403 || xhr.status === 400) {
window.location.reload();
}
consecutiveErrors++;
}
function pollStatus() {
$.post(ajaxurl, { action: 'aap_check_status', nonce: aapData.nonce_status })
.done(function (res) {
consecutiveErrors = 0;
if (!res.success) return;
var was = serverProcessing;
serverProcessing = res.data.processing;
setBadge(serverProcessing, res.data.abort_requested);
setRunBtn(serverProcessing);
if (res.data.last_run && $lastRun.length) {
$lastRun.html(s.last_run_prefix + ' <strong>' + $('<span>').text(res.data.last_run).html() + '</strong>');
}
// Execução terminou → recarrega a lista de últimos posts uma única vez
if (was && !serverProcessing && aapData.tab === 'execucoes') {
setTimeout(function () {
if (window.location.hash !== '#no-reload') window.location.reload();
}, 1500);
}
})
.fail(handleAjaxError);
}
setInterval(pollStatus, 3000);
// ── Iniciar pipeline (retorna imediatamente — roda em processo separado) ─
$runBtn.on('click', function () {
setRunBtn(true);
setBadge(true, false);
feedback($runStatus, s.preparing, 'pending');
$abortStatus.text('');
saveSettings().always(function () {
$.post(ajaxurl, { action: 'aap_start_pipeline', nonce: aapData.nonce_start })
.done(function (res) {
if (!res.success) {
showRunError(res.data || s.error_start);
setRunBtn(false);
setBadge(false, false);
} else {
feedback($runStatus, s.processing_feeds, 'pending');
}
})
.fail(function (xhr) {
var detail = xhr.responseText ? xhr.responseText.substring(0, 300) : s.comm_error;
showRunError('HTTP ' + xhr.status + ' — ' + detail);
setRunBtn(false);
setBadge(false, false);
});
});
});
// ── Abortar — encerra o processo imediatamente via PID ──────────────────
function doAbort() {
$abortBtn.prop('disabled', true);
feedback($abortStatus, s.aborting, 'pending');
setBadge(true, true);
$.post(ajaxurl, { action: 'aap_abort_execution', nonce: aapData.nonce_abort })
.done(function (res) {
if (res.success) {
setBadge(false, false);
setRunBtn(false);
$abortStatus.text('');
feedback($runStatus, s.aborted, 'warn');
} else {
feedback($abortStatus, res.data || s.error_abort, 'error');
$abortBtn.prop('disabled', false);
setBadge(serverProcessing, false);
}
})
.fail(function () {
feedback($abortStatus, s.comm_error, 'error');
$abortBtn.prop('disabled', false);
});
}
$abortBtn.on('click', doAbort);
// ── Geração automática ──────────────────────────────────────────────────
var $toggleBtn = $('#rap-toggle-auto');
var $autoBadge = $('#rap-auto-badge');
var $autoBadgeDot = $('#rap-auto-badge-dot');
var $autoBadgeText = $('#rap-auto-badge-text');
var $nextRunEl = $('#rap-next-run');
var $autoModeWrap = $('#rap-auto-mode-wrap');
var $modeSchedule = $('#rap-mode-schedule');
var $scheduleWrap = $('#rap-schedule-wrap');
var $scheduledAt = $('#rap-scheduled-at');
var $autoStatus = $('#rap-auto-status');
var $interval = $('#rap-auto-interval');
var $unit = $('#rap-auto-unit');
var autoEnabled = aapData.auto_enabled;
// Mostra/esconde o campo de agendamento conforme o rádio selecionado
$('input[name="aap_auto_mode"]').on('change', function () {
if ($modeSchedule.is(':checked')) {
$scheduleWrap.show();
if (!$scheduledAt.val()) {
var d = new Date();
d.setHours(d.getHours() + 1, 0, 0, 0);
var pad = function (n) { return String(n).padStart(2, '0'); };
$scheduledAt.val(d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':00');
}
} else {
$scheduleWrap.hide();
}
});
// Intervalo do automático também é uma opção salva — o valor editável fica na aba
// Execuções, por isso o formulário de configurações é atualizado antes de qualquer save.
$interval.add($unit).on('change', mirrorToForm);
mirrorToForm();
function updateAutoUI(enabled, nextRun) {
autoEnabled = enabled;
$autoBadgeDot.toggleClass('is-on', !!enabled);
$autoBadgeText.text(enabled ? s.auto_active : s.auto_inactive);
$toggleBtn.text(enabled ? s.stop_auto : s.start_auto).toggleClass('aap-btn-outline', !!enabled).toggleClass('aap-btn-primary', !enabled);
if (enabled) {
$autoModeWrap.hide();
if (nextRun) $nextRunEl.html(s.next_run + ' <strong>' + nextRun + '</strong>').show();
} else {
$autoModeWrap.show();
$nextRunEl.hide();
}
}
if (aapData.auto_enabled) updateAutoUI(true, aapData.next_run);
$toggleBtn.on('click', function () {
var $btn = $(this).prop('disabled', true);
var enable = !autoEnabled;
if (!enable) {
// Parando o automático — salva configs antes
feedback($autoStatus, '<em>' + s.wait + '</em>', 'pending');
saveSettings().always(function () {
$.post(ajaxurl, { action: 'aap_toggle_auto', nonce: aapData.nonce_toggle_auto, enable: 0 })
.done(function (res) {
if (res.success) {
updateAutoUI(false, '');
feedback($autoStatus, $('<span>').text(s.auto_paused).html(), 'pending');
} else {
feedback($autoStatus, s.error_prefix + ' ' + (res.data || s.error_start), 'error');
}
})
.fail(function () { feedback($autoStatus, s.comm_error, 'error'); })
.always(function () { $btn.prop('disabled', false); });
});
return;
}
// Iniciando o automático — valida o agendamento se necessário
var mode = $modeSchedule.is(':checked') ? 'schedule' : 'now';
var scheduledAt = 0;
if (mode === 'schedule') {
var val = $scheduledAt.val();
if (!val) {
feedback($autoStatus, s.schedule_required, 'error');
$btn.prop('disabled', false);
return;
}
scheduledAt = Math.floor(new Date(val).getTime() / 1000);
if (scheduledAt <= Math.floor(Date.now() / 1000)) {
feedback($autoStatus, s.schedule_past, 'error');
$btn.prop('disabled', false);
return;
}
}
feedback($autoStatus, '<em>' + s.wait + '</em>', 'pending');
// Salva configs (incluindo o intervalo exibido aqui) antes de iniciar
saveSettings().always(function () {
$.post(ajaxurl, {
action: 'aap_toggle_auto',
nonce: aapData.nonce_toggle_auto,
enable: 1,
mode: mode,
scheduled_at: scheduledAt,
interval_value: $interval.val(),
interval_unit: $unit.val(),
})
.done(function (res) {
if (res.success) {
updateAutoUI(res.data.enabled, res.data.next_run);
feedback($autoStatus, res.data.enabled ? s.auto_started : s.auto_paused, res.data.enabled ? 'success' : 'pending');
} else {
feedback($autoStatus, s.error_prefix + ' ' + (res.data || s.error_start), 'error');
}
})
.fail(function () { feedback($autoStatus, s.comm_error, 'error'); })
.always(function () { $btn.prop('disabled', false); });
});
});
// ── Log (banco de dados, paginado) ─────────────────────────────────────
var $logBox = $('#rap-log-box');
var $logStatus = $('#rap-log-status');
var $logMore = $('#rap-log-more');
var $logLevel = $('#rap-log-level');
var logPage = 1;
var logFetching = false;
var logTimer = null;
var logIds = {};
if (pref('live', undefined) === false) {
$('.aap-pref-live').prop('checked', false);
}
function logRow(row) {
var cls = row.level === 'error' ? 'erro' : row.level === 'warn' ? 'aviso' : 'info';
return '<div class="aap-log-line" data-id="' + row.id + '">'
+ '<span class="aap-log-time">' + $('<span>').text(row.time).html() + '</span>'
+ '<span class="aap-log-level aap-log-level-' + cls + '">' + String(row.level).toUpperCase() + '</span>'
+ '<span class="aap-log-msg">' + $('<span>').text(row.message).html() + '</span>'
+ '</div>';
}
function appendRows(rows, prepend) {
var html = '';
$.each(rows, function (_, row) {
if (logIds[row.id]) return;
logIds[row.id] = true;
html += logRow(row);
});
if (!html) return;
if (prepend) {
$logBox.prepend(html);
} else {
$logBox.append(html);
}
}
function fetchLogs(page, mode) {
if (logFetching || !$logBox.length) return;
logFetching = true;
$.post(ajaxurl, {
action: 'aap_fetch_logs',
nonce: aapData.nonce_logs,
page: page,
per_page: aapData.log_per_page || 50,
level: $logLevel.length ? $logLevel.val() : ''
})
.done(function (res) {
if (!res.success) return;
var rows = res.data.rows || [];
if (mode === 'reset') {
$logBox.empty();
logIds = {};
}
appendRows(rows, mode === 'new');
if (!$logBox.children().length) {
$logBox.html('<span class="aap-log-empty">' + $('<span>').text(s.log_empty).html() + '</span>');
} else {
$logBox.find('.aap-log-empty').remove();
}
$logMore.prop('hidden', !res.data.has_more);
$logStatus.text(s.log_updated + ' ' + new Date().toLocaleTimeString());
})
.fail(function () {
$logStatus.text(s.comm_error);
})
.always(function () {
logFetching = false;
});
}
function reloadLogs() {
logPage = 1;
fetchLogs(1, 'reset');
}
function startLogPolling() {
if (!$logBox.length) return;
stopLogPolling();
reloadLogs();
if (pref('live', undefined)) {
logTimer = setInterval(function () { fetchLogs(1, 'new'); }, 4000);
}
}
function stopLogPolling() {
clearInterval(logTimer);
logTimer = null;
}
if ($logMore.length) {
$logMore.on('click', function () {
logPage += 1;
fetchLogs(logPage, 'more');
});
}
if ($logLevel.length) {
$logLevel.on('change', reloadLogs);
}
document.addEventListener('visibilitychange', function () {
if (!$logBox.length) return;
if (document.hidden) stopLogPolling(); else startLogPolling();
});
if ($logBox.length) startLogPolling();
});
@@ -0,0 +1,22 @@
/* Automatic AI Posts — post edit screen (cover image refresh). */
#aap-refresh-image .aap-refresh-desc {
margin-top: 0;
color: #646970;
}
#aap-refresh-image .aap-refresh-btn {
width: 100%;
justify-content: center;
}
#aap-refresh-image .aap-refresh-feedback {
display: block;
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
}
#aap-refresh-image .aap-refresh-feedback.is-working { color: #646970; }
#aap-refresh-image .aap-refresh-feedback.is-success { color: #15803d; }
#aap-refresh-image .aap-refresh-feedback.is-error { color: #b32d2e; }
@@ -0,0 +1,57 @@
/**
* Post edit screen: "Search a new image" button.
*
* The AI query plus the image download can take a few seconds, so the button
* reports progress and updates the cover preview when it finishes.
*/
(function ($) {
'use strict';
$(document).on('click', '#aap-refresh-image-btn', function (e) {
e.preventDefault();
var $btn = $(this);
var $feedback = $('#aap-refresh-image-feedback');
var i18n = (window.aapEditor && aapEditor.i18n) || {};
if ($btn.data('busy')) return;
$btn.data('busy', true).prop('disabled', true);
$feedback.removeClass('is-error is-success').addClass('is-working').text(i18n.working || '...');
$.post(aapEditor.ajaxurl, {
action: 'aap_refresh_image',
nonce: $btn.data('nonce'),
post_id: $btn.data('post')
})
.done(function (res) {
if (res && res.success && res.data && res.data.updated) {
$feedback.removeClass('is-working').addClass('is-success').text(i18n.success || 'OK');
applyThumbnail(res.data);
} else if (res && res.success) {
$feedback.removeClass('is-working').addClass('is-error').text(i18n.no_image || 'No image found.');
} else {
$feedback.removeClass('is-working').addClass('is-error').text(i18n.error || 'Error.');
}
})
.fail(function () {
$feedback.removeClass('is-working').addClass('is-error').text(i18n.error || 'Error.');
})
.always(function () {
$btn.data('busy', false).prop('disabled', false);
});
});
/**
* Swap the preview inside the core "Featured image" box without reloading.
*/
function applyThumbnail(data) {
if (!data.thumbnail) return;
var $img = $('#postimagediv #postimagediv-thumbnail img, #postimagediv .inside img').first();
if ($img.length) {
$img.attr('src', data.thumbnail);
}
}
}(jQuery));
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff">
<path fill-rule="evenodd" d="M6.5 8h6a2.9 2.9 0 0 1 2.9 2.9v3.6a2.9 2.9 0 0 1-2.9 2.9h-6a2.9 2.9 0 0 1-2.9-2.9v-3.6A2.9 2.9 0 0 1 6.5 8Zm-0.8 4.1a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 1 0-2.5 0Zm3.6 0a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 1 0-2.5 0Z"/>
<rect x="6.85" y="17.1" width="1.3" height="2.5" rx="0.65"/>
<rect x="10.85" y="17.1" width="1.3" height="2.5" rx="0.65"/>
<rect x="13.95" y="5.9" width="1.3" height="1.5" rx="0.65"/>
<circle cx="17.7" cy="4.5" r="1.05"/>
<circle cx="17.7" cy="9.1" r="1.05"/>
<rect x="15.6" y="8.45" width="1.1" height="1.3" rx="0.65"/>
<rect x="18.7" y="8.45" width="1.1" height="1.3" rx="0.65"/>
</svg>

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

@@ -0,0 +1,57 @@
<?php
/**
* Plugin Name: Automatic AI Posts
* Plugin URI: https://gustavoo.me/
* Description: Modular AI content pipeline: RSS/GNews → AI rewrite → image → post.
* Version: 2.0.0
* Author: Gustavo
* Author URI: https://gustavoo.me/
* Text Domain: automatic-ai-posts
*/
defined('ABSPATH') || exit;
define('AAP_VERSION', '2.0.0');
define('AAP_PLUGIN_FILE', __FILE__);
define('AAP_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('AAP_PLUGIN_URL', plugin_dir_url(__FILE__));
require_once AAP_PLUGIN_DIR . 'src/Core/Autoloader.php';
AAP\Core\Autoloader::register(AAP_PLUGIN_DIR . 'src/');
require_once AAP_PLUGIN_DIR . 'src/Core/Options.php';
/**
* Module tree. Order here is irrelevant: the registry resolves dependencies.
* Each entry must be a class extending AAP\Core\Module.
*/
add_action('plugins_loaded', static function (): void {
AAP\Core\Registry::add(new AAP\Modules\I18n\I18nModule());
AAP\Core\Registry::add(new AAP\Modules\Logs\LogsModule());
AAP\Core\Registry::add(new AAP\Modules\Ai\AiModule());
AAP\Core\Registry::add(new AAP\Modules\Feeds\FeedsModule());
AAP\Core\Registry::add(new AAP\Modules\Content\ContentModule());
AAP\Core\Registry::add(new AAP\Modules\Images\ImagesModule());
AAP\Core\Registry::add(new AAP\Modules\Posts\PostsModule());
AAP\Core\Registry::add(new AAP\Modules\Pipeline\PipelineModule());
AAP\Core\Registry::add(new AAP\Modules\Schedule\ScheduleModule());
AAP\Core\Registry::add(new AAP\Modules\Admin\AdminModule());
AAP\Core\Registry::add(new AAP\Modules\Editor\EditorModule());
AAP\Core\Registry::boot();
}, 5);
register_activation_hook(__FILE__, static function (): void {
AAP\Core\Options::install_defaults();
AAP\Modules\Logs\LogRepository::install();
AAP\Modules\Logs\Logger::info('Plugin activated.', 'lifecycle');
});
register_deactivation_hook(__FILE__, static function (): void {
AAP\Core\Options::set('aap_auto_enabled', false);
delete_option('aap_next_auto_run');
delete_transient('aap_auto_spawn_lock');
wp_clear_scheduled_hook('aap_auto_run');
AAP\Modules\Logs\Logger::info('Plugin deactivated.', 'lifecycle');
});
@@ -0,0 +1,30 @@
<?php
/**
* CLI script executed by the system cron (every minute).
* It only checks whether the automatic run is due; the heavy work happens in
* bin/run-pipeline.php, spawned by the scheduler.
*
* Usage: php bin/auto-check.php
*/
$dir = __DIR__;
for ($i = 0; $i < 10; $i++) {
if (file_exists($dir . '/wp-load.php')) {
break;
}
$dir = dirname($dir);
}
if (!file_exists($dir . '/wp-load.php')) {
fwrite(STDERR, "[Automatic AI Posts] wp-load.php not found.\n");
exit(1);
}
set_time_limit(30);
ignore_user_abort(true);
define('WP_INSTALLING', false);
require_once $dir . '/wp-load.php';
do_action('aap_cron_tick');
@@ -0,0 +1,55 @@
<?php
/**
* CLI bootstrapper: runs the pipeline in a separate PHP process.
* Usage: php bin/run-pipeline.php <author_id>
*/
$dir = __DIR__;
for ($i = 0; $i < 10; $i++) {
if (file_exists($dir . '/wp-load.php')) {
break;
}
$dir = dirname($dir);
}
if (!file_exists($dir . '/wp-load.php')) {
fwrite(STDERR, "[Automatic AI Posts] wp-load.php not found.\n");
exit(1);
}
set_time_limit(0);
ignore_user_abort(true);
ini_set('memory_limit', '-1');
define('WP_INSTALLING', false);
require_once $dir . '/wp-load.php';
$author_id = isset($argv[1]) ? (int) $argv[1] : 0;
register_shutdown_function(static function (): void {
$error = error_get_last();
if (!$error || !in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) {
return;
}
\AAP\Modules\Logs\Logger::error(
'Fatal PHP error: ' . $error['message'] . ' at ' . $error['file'] . ':' . $error['line'],
'pipeline'
);
\AAP\Modules\Pipeline\RunState::stop();
});
try {
do_action('aap_run_pipeline_cli', $author_id);
} catch (\Throwable $e) {
\AAP\Modules\Logs\Logger::error(
'Uncaught exception: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine(),
'pipeline'
);
\AAP\Modules\Pipeline\RunState::stop();
exit(1);
}
@@ -0,0 +1,103 @@
<?php
defined('ABSPATH') || exit;
/**
* de
*/
return [
'nav_label' => 'Plugin-Navigation',
'tab_execucoes' => 'Ausführungen',
'tab_configuracoes' => 'Einstellungen',
'tab_log' => 'Live-Log',
'tab_ajuda' => 'Hilfe',
'config_subtitle' => 'Alle Plugin-Optionen nach Kategorie konfigurieren.',
'config_categories' => 'Einstellungskategorien',
'section_general' => 'Allgemein',
'section_general_desc' => 'Haupteinstellungen des Plugins.',
'section_prompts' => 'Prompts und Filter',
'section_prompts_desc' => 'Redaktionsprofil, Ausschlüsse und Umschreib-Anweisungen.',
'section_feeds' => 'RSS-Feeds',
'section_feeds_desc' => 'Nachrichtenquellen per RSS.',
'section_gnews' => 'GNews.io',
'section_gnews_desc' => 'API-Schlüssel und Kategorien.',
'section_ai' => 'Künstliche Intelligenz',
'section_ai_desc' => 'Anbieter, Modell und Parameter.',
'section_images' => 'Bilder',
'section_images_desc' => 'Such- und Bildunterschrift-Anweisungen.',
'section_other' => 'Weitere Einstellungen',
'section_other_desc' => 'Version und Support.',
'field_pub_mode' => 'Veröffentlichungsmodus',
'pub_mode_draft_label' => 'Als Entwurf speichern',
'pub_mode_pub_label' => 'Automatisch veröffentlichen',
'field_language' => 'Sprache der Beiträge',
'field_app_language' => 'Sprache der App',
'field_posts_per_run' => 'Beiträge pro Lauf',
'min_posts' => 'Min.:',
'max_posts' => 'Max.:',
'field_editorial' => 'Redaktionsprofil',
'field_negative' => 'Negativprofil',
'rewrite_label' => 'Umschreiben',
'field_rewrite' => 'Umschreib-Anweisungen',
'field_feeds' => 'Feed-URLs',
'field_feed_max' => 'Beiträge pro Feed',
'field_gnews_key' => 'API-Schlüssel',
'field_gnews_cats' => 'Kategorien',
'field_gnews_max' => 'Beiträge pro Kategorie',
'field_provider' => 'KI-Anbieter',
'field_model' => 'Modell',
'field_ai_limits' => 'Timeout und Versuche',
'timeout_label' => 'Timeout',
'retries_label' => 'Versuche',
'field_image_instructions' => 'Bild-Anweisungen',
'field_image_caption' => 'Konfigurierbare Bildunterschrift',
'other_version' => 'Plugin-Version',
'other_support' => 'Support und Credits',
'save_all' => 'Alle Einstellungen speichern',
'discard_changes' => 'Änderungen verwerfen',
'unsaved_changes' => 'Ungespeicherte Änderungen',
'exec_subtitle' => 'Ausführungen verfolgen und Automatik steuern.',
'running_now' => 'Läuft...',
'no_execution' => 'Kein Prozess läuft',
'started_in' => 'Gestartet: %s',
'idle_desc' => 'Kein Lauf aktiv.',
'last_run' => 'Zuletzt:',
'never' => 'nie',
'running_btn' => 'Läuft...',
'abort_btn' => 'Lauf abbrechen',
'status_running' => 'Läuft',
'status_idle' => 'Gestoppt',
'section_run_now' => 'Jetzt ausführen',
'card_auto' => 'Automatische Generierung',
'auto_status_label' => 'Status',
'auto_active' => 'Aktiv',
'auto_inactive' => 'Inaktiv',
'next_run' => 'Nächster:',
'auto_first_run' => 'Erster Lauf',
'start_now' => 'Jetzt starten',
'set_schedule' => 'Zeitplan festlegen',
'field_auto_period' => 'Zeitraum der Automatik',
'stop_auto_btn' => 'Automatik pausieren',
'start_auto_btn' => 'Automatik starten',
'log_title' => 'Live-Log',
'log_panel_subtitle' => 'Ereignisse und Fehler verfolgen.',
'log_live' => 'Automatisch aktualisieren',
'log_level' => 'Stufe',
'log_level_all' => 'Alle Stufen',
'log_loading' => 'Lädt...',
'log_load_more' => 'Mehr laden',
'help_subtitle' => 'Plugin-Credits.',
'created_by' => 'erstellt von',
'licence_label' => 'GPLv2-Lizenz',
'help_website' => 'Website des Autors',
'plugin_active' => 'Plugin aktiv',
'version_label' => 'Version %s',
'js_run_now' => 'Jetzt ausführen',
'js_settings_saved' => 'Einstellungen gespeichert.',
'js_comm_error' => 'Kommunikation fehlgeschlagen.',
'help_page_title' => 'Hilfe, Credits und Spende',
'section_donate' => 'Projekt unterstützen',
'section_credits' => 'Credits',
'section_about' => 'Über das Plugin',
'donate_btn' => 'Mit Pix spenden',
'or_label' => 'oder',
];
@@ -0,0 +1,201 @@
<?php
defined('ABSPATH') || exit;
/**
* English
*/
return [
'nav_label' => 'Plugin navigation',
'tab_execucoes' => 'Runs',
'tab_configuracoes' => 'Settings',
'tab_log' => 'Live log',
'tab_ajuda' => 'Help',
'config_subtitle' => 'Configure every plugin option, organized by category.',
'config_categories' => 'Settings categories',
'section_general' => 'General',
'section_general_desc' => 'Main plugin settings.',
'section_prompts' => 'Prompts and Filter',
'section_prompts_desc' => 'Editorial profile, exclusions and rewrite instructions.',
'section_feeds' => 'RSS Feeds',
'section_feeds_desc' => 'News sources via RSS.',
'section_gnews' => 'GNews.io',
'section_gnews_desc' => 'API key and categories for news search.',
'section_ai' => 'Artificial Intelligence',
'section_ai_desc' => 'AI provider, model and parameters.',
'section_other' => 'Other Settings',
'section_other_desc' => 'Version and support.',
'field_pub_mode' => 'Publish mode',
'pub_mode_draft_label' => 'Save as draft',
'pub_mode_pub_label' => 'Publish automatically',
'pub_mode_desc' => 'Applies to both the manual button and automatic generation.',
'field_language' => 'Post language',
'language_desc' => 'Defines the language the AI writes in.',
'field_app_language' => 'App language',
'app_language_desc' => 'Language of the admin panel.',
'field_posts_per_run' => 'Posts per run',
'min_posts' => 'Min:',
'max_posts' => 'Max:',
'field_editorial' => 'Editorial profile',
'editorial_ph' => 'E.g. Tech, AI and startups blog. Prioritize generative AI news.',
'editorial_desc' => 'The AI uses this profile to pick the most relevant articles.',
'field_negative' => 'Negative profile',
'negative_ph' => 'E.g. Avoid politics, sports and clickbait.',
'negative_desc' => 'Topics the AI must <strong>avoid</strong> when selecting articles.',
'rewrite_label' => 'Rewriting',
'image_label' => 'Cover image',
'field_rewrite' => 'Rewrite instructions',
'rewrite_ph' => 'E.g. Write in a formal tone, use data and avoid clickbait.',
'rewrite_desc' => 'Extra instructions sent to the AI when rewriting.',
'field_feeds' => 'Feed URLs',
'feeds_desc' => 'One feed per line.',
'field_feed_max' => 'Articles per feed',
'feed_max_desc' => 'How many recent articles per feed are evaluated.',
'field_gnews_key' => 'API key',
'key_saved_hint' => 'A key is already saved. Leave the field blank to keep it.',
'gnews_key_desc' => 'Get it at <a href="https://gnews.io" target="_blank" rel="noopener">gnews.io</a>.',
'field_gnews_cats' => 'Categories',
'gnews_cats_desc' => 'A separate request is made for each selected category.',
'field_gnews_max' => 'Articles per category',
'gnews_max_desc' => 'On the GNews free plan the maximum is 10 articles per request.',
'field_provider' => 'AI provider',
'provider_desc' => 'The provider used for every AI call.',
'field_nvidia_key' => 'NVIDIA API key',
'nvidia_key_desc' => 'Get it at <a href="https://build.nvidia.com" target="_blank" rel="noopener">build.nvidia.com</a>.',
'field_openai_key' => 'OpenAI API key',
'openai_key_desc' => 'Get it at <a href="https://platform.openai.com" target="_blank" rel="noopener">platform.openai.com</a>.',
'field_anthropic_key' => 'Anthropic API key',
'anthropic_key_desc' => 'Get it at <a href="https://console.anthropic.com" target="_blank" rel="noopener">console.anthropic.com</a>.',
'field_openrouter_key' => 'OpenRouter API key',
'openrouter_key_desc' => 'Get it at <a href="https://openrouter.ai" target="_blank" rel="noopener">openrouter.ai</a>.',
'field_custom_url' => 'Base URL',
'custom_url_desc' => 'OpenAI-compatible endpoint, including /v1.',
'field_custom_key' => 'API key',
'custom_key_desc' => 'Key sent as Bearer to the endpoint above.',
'field_vertex_project' => 'Project ID',
'field_vertex_location' => 'Location',
'field_vertex_sa' => 'Service account (JSON)',
'vertex_sa_desc' => 'Full contents of the Google Cloud service account JSON file.',
'field_model' => 'Model',
'model_desc' => 'Leave blank to use the provider default model.',
'field_ai_limits' => 'Timeout and retries',
'timeout_label' => 'Timeout',
'retries_label' => 'Retries',
'ai_limits_desc' => 'Applies to every AI call.',
'ai_test_label' => 'Test connection',
'ai_test_btn' => 'Test now',
'ai_test_desc' => 'Sends a short call to the selected provider and shows the reply or the error.',
'ai_test_testing' => 'Testing…',
'ai_test_ok' => 'Connection works',
'field_image_instructions' => 'Image instructions',
'image_instructions_ph' => 'E.g. Prefer real photos of people working, no text in the image.',
'image_instructions_desc' => 'The AI uses these instructions to build the DuckDuckGo query and write the image texts.',
'field_image_caption' => 'Configurable caption',
'image_caption_ph' => 'E.g. Always mention the company name. Max 120 characters.',
'image_caption_desc' => 'Style or rules the image caption must follow.',
'other_version' => 'Plugin version',
'other_support' => 'Support and credits',
'save_all' => 'Save all settings',
'discard_changes' => 'Discard changes',
'unsaved_changes' => 'Unsaved changes',
'exec_subtitle' => 'Follow plugin runs and control automatic generation.',
'running_now' => 'Running now...',
'no_execution' => 'No process running',
'started_in' => 'Started at: %s',
'idle_desc' => 'No run in progress right now.',
'processing_feeds' => 'Processing feeds...',
'last_run' => 'Last:',
'never' => 'never',
'running_btn' => 'Running...',
'abort_btn' => 'Abort run',
'exec_note' => 'Updated automatically every 3 seconds. Abort ends the process immediately.',
'status_running' => 'Running',
'status_idle' => 'Stopped',
'section_run_now' => 'Run now',
'card_auto' => 'Automatic generation',
'auto_status_label' => 'Status',
'auto_active' => 'Active',
'auto_inactive' => 'Inactive',
'next_run' => 'Next:',
'auto_first_run' => 'First run',
'start_now' => 'Start now',
'set_schedule' => 'Set a schedule',
'schedule_desc' => 'Pick the date and time for the first run.',
'field_auto_period' => 'Automatic generation period',
'auto_period_hint' => 'Interval between automatic runs.',
'stop_auto_btn' => 'Pause automatic',
'start_auto_btn' => 'Start automatic',
'log_title' => 'Live log',
'log_panel_subtitle' => 'Follow plugin events and errors.',
'log_live' => 'Auto refresh',
'log_level' => 'Level',
'log_level_all' => 'All levels',
'log_loading' => 'Loading...',
'log_load_more' => 'Load more',
'help_subtitle' => 'Plugin credits.',
'created_by' => 'created by',
'licence_label' => 'GPLv2 License',
'help_website' => 'Author website',
'plugin_active' => 'Plugin active',
'version_label' => 'Version %s',
'js_no_execution' => 'No process running',
'js_running' => 'Running...',
'js_aborting' => 'Aborting...',
'js_run_now' => 'Run now',
'js_processing' => 'Processing feeds...',
'js_error_prefix' => 'Error:',
'js_aborted' => 'Run aborted.',
'js_wait' => 'Please wait...',
'js_auto_started' => 'Automatic generation enabled.',
'js_auto_paused' => 'Automatic generation paused.',
'js_comm_error' => 'Communication failed.',
'js_error_start' => 'Could not start.',
'js_error_abort' => 'Could not abort.',
'js_start_auto' => 'Start automatic',
'js_stop_auto' => 'Pause automatic',
'js_log_empty' => 'No logs yet.',
'js_log_updated' => 'Updated',
'js_next_run' => 'Next:',
'js_auto_active' => 'Active',
'js_auto_inactive' => 'Inactive',
'js_schedule_required' => 'Pick a date and time.',
'js_schedule_past' => 'The date must be in the future.',
'js_settings_saved' => 'Settings saved.',
'js_preparing' => 'Preparing...',
'js_running_feeds' => 'Processing feeds...',
'js_sleeping' => 'Waiting...',
'gnews_categories' => ['general' => 'General', 'world' => 'World', 'nation' => 'Nation', 'business' => 'Business', 'technology' => 'Technology', 'entertainment' => 'Entertainment', 'sports' => 'Sports', 'science' => 'Science', 'health' => 'Health'],
'auto_period_units' => ['minutes' => 'minutes', 'hours' => 'hours', 'days' => 'days'],
'help_page_title' => 'Help, Credits and Donation',
'help_page_subtitle' => 'Find help, see the plugin credits and support the project.',
'section_donate' => 'Support the Project',
'section_donate_desc' => 'Your support keeps the plugin updated and evolving.',
'donate_title' => 'Make a donation',
'donate_desc' => 'Any amount helps a lot to keep this plugin in development and to bring new features to you.',
'donate_btn' => 'Donate with Pix',
'donate_copied' => 'Copy and paste key copied!',
'donate_qr_hint' => 'Point your camera',
'or_label' => 'or',
'section_credits' => 'Credits',
'section_credits_desc' => 'Thanks to everyone who makes this project possible.',
'credit_developer' => 'Lead Developer',
'section_about' => 'About the Plugin',
'section_about_desc' => 'Important information about Automatic AI Posts.',
'about_desc' => 'WordPress plugin to generate and publish posts automatically with artificial intelligence, simply and efficiently.',
'about_feature_1' => 'Automatic AI posts',
'about_feature_2' => 'Multiple settings',
'about_feature_3' => 'Detailed logs',
'about_feature_4' => 'RSS feed support',
'about_feature_5' => 'Scheduled runs',
'about_feature_6' => 'Multiple AI providers',
'about_github' => 'Project on GitHub',
'donate_key_label' => 'Copy and paste key:',
'donate_no_qr' => 'Save the QR code image at admin/pix-qrcode.png',
'editor_box_title' => 'Automatic AI Posts',
'editor_desc' => 'Searches a new cover image and regenerates the alt, caption and description, exactly like the pipeline.',
'editor_btn_has_image' => 'Search a new image',
'editor_btn_no_image' => 'Search a cover image',
'editor_working' => 'Searching a new image…',
'editor_success' => 'Cover image updated.',
'editor_no_image' => 'No usable image was found. The current cover was kept.',
'editor_error' => 'Could not search the image. Check the plugin logs.',
];
@@ -0,0 +1,103 @@
<?php
defined('ABSPATH') || exit;
/**
* es
*/
return [
'nav_label' => 'Navegación del plugin',
'tab_execucoes' => 'Ejecuciones',
'tab_configuracoes' => 'Ajustes',
'tab_log' => 'Log en vivo',
'tab_ajuda' => 'Ayuda',
'config_subtitle' => 'Configure todas las opciones del plugin por categoría.',
'config_categories' => 'Categorías de ajustes',
'section_general' => 'General',
'section_general_desc' => 'Ajustes principales del plugin.',
'section_prompts' => 'Prompts y Filtro',
'section_prompts_desc' => 'Perfil editorial, exclusiones e instrucciones de reescritura.',
'section_feeds' => 'Feeds RSS',
'section_feeds_desc' => 'Fuentes de noticias por RSS.',
'section_gnews' => 'GNews.io',
'section_gnews_desc' => 'Clave de API y categorías para buscar noticias.',
'section_ai' => 'Inteligencia Artificial',
'section_ai_desc' => 'Proveedor, modelo y parámetros de la IA.',
'section_images' => 'Imágenes',
'section_images_desc' => 'Instrucciones de búsqueda y pie de foto.',
'section_other' => 'Otros ajustes',
'section_other_desc' => 'Versión y soporte.',
'field_pub_mode' => 'Modo de publicación',
'pub_mode_draft_label' => 'Guardar como borrador',
'pub_mode_pub_label' => 'Publicar automáticamente',
'field_language' => 'Idioma de los posts',
'field_app_language' => 'Idioma de la aplicación',
'field_posts_per_run' => 'Posts por ejecución',
'min_posts' => 'Mín:',
'max_posts' => 'Máx:',
'field_editorial' => 'Perfil editorial',
'field_negative' => 'Perfil negativo',
'rewrite_label' => 'Reescritura',
'field_rewrite' => 'Instrucciones de reescritura',
'field_feeds' => 'URLs de los feeds',
'field_feed_max' => 'Artículos por feed',
'field_gnews_key' => 'Clave de API',
'field_gnews_cats' => 'Categorías',
'field_gnews_max' => 'Artículos por categoría',
'field_provider' => 'Proveedor de IA',
'field_model' => 'Modelo',
'field_ai_limits' => 'Timeout e intentos',
'timeout_label' => 'Timeout',
'retries_label' => 'Intentos',
'field_image_instructions' => 'Instrucciones de imagen',
'field_image_caption' => 'Pie de foto configurable',
'other_version' => 'Versión del plugin',
'other_support' => 'Soporte y créditos',
'save_all' => 'Guardar todos los ajustes',
'discard_changes' => 'Descartar cambios',
'unsaved_changes' => 'Cambios sin guardar',
'exec_subtitle' => 'Sigue las ejecuciones y controla la generación automática.',
'running_now' => 'Ejecutando ahora...',
'no_execution' => 'Ningún proceso en ejecución',
'started_in' => 'Iniciado: %s',
'idle_desc' => 'Ninguna ejecución en curso.',
'last_run' => 'Última:',
'never' => 'nunca',
'running_btn' => 'En ejecución...',
'abort_btn' => 'Abortar ejecución',
'status_running' => 'En ejecución',
'status_idle' => 'Detenido',
'section_run_now' => 'Ejecutar ahora',
'card_auto' => 'Generación automática',
'auto_status_label' => 'Estado',
'auto_active' => 'Activa',
'auto_inactive' => 'Inactiva',
'next_run' => 'Próxima:',
'auto_first_run' => 'Primera ejecución',
'start_now' => 'Empezar ahora',
'set_schedule' => 'Definir un horario',
'field_auto_period' => 'Periodo de generación automática',
'stop_auto_btn' => 'Pausar automático',
'start_auto_btn' => 'Iniciar automático',
'log_title' => 'Log en vivo',
'log_panel_subtitle' => 'Sigue los eventos y errores del plugin.',
'log_live' => 'Actualización automática',
'log_level' => 'Nivel',
'log_level_all' => 'Todos los niveles',
'log_loading' => 'Cargando...',
'log_load_more' => 'Cargar más',
'help_subtitle' => 'Créditos del plugin.',
'created_by' => 'creado por',
'licence_label' => 'Licencia GPLv2',
'help_website' => 'Sitio del autor',
'plugin_active' => 'Plugin activo',
'version_label' => 'Versión %s',
'js_run_now' => 'Ejecutar ahora',
'js_settings_saved' => 'Ajustes guardados.',
'js_comm_error' => 'Fallo de comunicación.',
'help_page_title' => 'Ayuda, Créditos y Donación',
'section_donate' => 'Apoya el Proyecto',
'section_credits' => 'Créditos',
'section_about' => 'Sobre el Plugin',
'donate_btn' => 'Donar con Pix',
'or_label' => 'o',
];
@@ -0,0 +1,103 @@
<?php
defined('ABSPATH') || exit;
/**
* fr
*/
return [
'nav_label' => 'Navigation du plugin',
'tab_execucoes' => 'Exécutions',
'tab_configuracoes' => 'Réglages',
'tab_log' => 'Journal en direct',
'tab_ajuda' => 'Aide',
'config_subtitle' => 'Configurez toutes les options du plugin par catégorie.',
'config_categories' => 'Catégories de réglages',
'section_general' => 'Général',
'section_general_desc' => 'Réglages principaux du plugin.',
'section_prompts' => 'Prompts et Filtre',
'section_prompts_desc' => 'Profil éditorial, exclusions et instructions de réécriture.',
'section_feeds' => 'Flux RSS',
'section_feeds_desc' => 'Sources d’actualités via RSS.',
'section_gnews' => 'GNews.io',
'section_gnews_desc' => 'Clé API et catégories de recherche.',
'section_ai' => 'Intelligence Artificielle',
'section_ai_desc' => 'Fournisseur, modèle et paramètres de l’IA.',
'section_images' => 'Images',
'section_images_desc' => 'Instructions de recherche et légende.',
'section_other' => 'Autres réglages',
'section_other_desc' => 'Version et support.',
'field_pub_mode' => 'Mode de publication',
'pub_mode_draft_label' => 'Enregistrer en brouillon',
'pub_mode_pub_label' => 'Publier automatiquement',
'field_language' => 'Langue des articles',
'field_app_language' => 'Langue de l’application',
'field_posts_per_run' => 'Articles par exécution',
'min_posts' => 'Min :',
'max_posts' => 'Max :',
'field_editorial' => 'Profil éditorial',
'field_negative' => 'Profil négatif',
'rewrite_label' => 'Réécriture',
'field_rewrite' => 'Instructions de réécriture',
'field_feeds' => 'URL des flux',
'field_feed_max' => 'Articles par flux',
'field_gnews_key' => 'Clé API',
'field_gnews_cats' => 'Catégories',
'field_gnews_max' => 'Articles par catégorie',
'field_provider' => 'Fournisseur d’IA',
'field_model' => 'Modèle',
'field_ai_limits' => 'Délai et tentatives',
'timeout_label' => 'Délai',
'retries_label' => 'Tentatives',
'field_image_instructions' => 'Instructions d’image',
'field_image_caption' => 'Légende configurable',
'other_version' => 'Version du plugin',
'other_support' => 'Support et crédits',
'save_all' => 'Enregistrer tous les réglages',
'discard_changes' => 'Annuler les modifications',
'unsaved_changes' => 'Modifications non enregistrées',
'exec_subtitle' => 'Suivez les exécutions et contrôlez la génération automatique.',
'running_now' => 'Exécution en cours...',
'no_execution' => 'Aucun processus en cours',
'started_in' => 'Démarré à : %s',
'idle_desc' => 'Aucune exécution en cours.',
'last_run' => 'Dernière :',
'never' => 'jamais',
'running_btn' => 'En cours...',
'abort_btn' => 'Arrêter l’exécution',
'status_running' => 'En cours',
'status_idle' => 'Arrêté',
'section_run_now' => 'Exécuter maintenant',
'card_auto' => 'Génération automatique',
'auto_status_label' => 'Statut',
'auto_active' => 'Active',
'auto_inactive' => 'Inactive',
'next_run' => 'Prochaine :',
'auto_first_run' => 'Première exécution',
'start_now' => 'Démarrer maintenant',
'set_schedule' => 'Définir un horaire',
'field_auto_period' => 'Période de génération automatique',
'stop_auto_btn' => 'Mettre en pause',
'start_auto_btn' => 'Démarrer l’automatique',
'log_title' => 'Journal en direct',
'log_panel_subtitle' => 'Suivez les événements et erreurs du plugin.',
'log_live' => 'Actualisation automatique',
'log_level' => 'Niveau',
'log_level_all' => 'Tous les niveaux',
'log_loading' => 'Chargement...',
'log_load_more' => 'Charger plus',
'help_subtitle' => 'Crédits du plugin.',
'created_by' => 'créé par',
'licence_label' => 'Licence GPLv2',
'help_website' => 'Site de l’auteur',
'plugin_active' => 'Plugin actif',
'version_label' => 'Version %s',
'js_run_now' => 'Exécuter maintenant',
'js_settings_saved' => 'Réglages enregistrés.',
'js_comm_error' => 'Échec de communication.',
'help_page_title' => 'Aide, Crédits et Don',
'section_donate' => 'Soutenez le Projet',
'section_credits' => 'Crédits',
'section_about' => 'À propos du Plugin',
'donate_btn' => 'Faire un don avec Pix',
'or_label' => 'ou',
];
@@ -0,0 +1,103 @@
<?php
defined('ABSPATH') || exit;
/**
* it
*/
return [
'nav_label' => 'Navigazione del plugin',
'tab_execucoes' => 'Esecuzioni',
'tab_configuracoes' => 'Impostazioni',
'tab_log' => 'Log in tempo reale',
'tab_ajuda' => 'Aiuto',
'config_subtitle' => 'Configura tutte le opzioni del plugin per categoria.',
'config_categories' => 'Categorie di impostazioni',
'section_general' => 'Generale',
'section_general_desc' => 'Impostazioni principali del plugin.',
'section_prompts' => 'Prompt e Filtro',
'section_prompts_desc' => 'Profilo editoriale, esclusioni e istruzioni di riscrittura.',
'section_feeds' => 'Feed RSS',
'section_feeds_desc' => 'Fonti di notizie via RSS.',
'section_gnews' => 'GNews.io',
'section_gnews_desc' => 'Chiave API e categorie.',
'section_ai' => 'Intelligenza Artificiale',
'section_ai_desc' => 'Provider, modello e parametri.',
'section_images' => 'Immagini',
'section_images_desc' => 'Istruzioni di ricerca e didascalia.',
'section_other' => 'Altre impostazioni',
'section_other_desc' => 'Versione e supporto.',
'field_pub_mode' => 'Modalità di pubblicazione',
'pub_mode_draft_label' => 'Salva come bozza',
'pub_mode_pub_label' => 'Pubblica automaticamente',
'field_language' => 'Lingua dei post',
'field_app_language' => 'Lingua dell’app',
'field_posts_per_run' => 'Post per esecuzione',
'min_posts' => 'Min:',
'max_posts' => 'Max:',
'field_editorial' => 'Profilo editoriale',
'field_negative' => 'Profilo negativo',
'rewrite_label' => 'Riscrittura',
'field_rewrite' => 'Istruzioni di riscrittura',
'field_feeds' => 'URL dei feed',
'field_feed_max' => 'Articoli per feed',
'field_gnews_key' => 'Chiave API',
'field_gnews_cats' => 'Categorie',
'field_gnews_max' => 'Articoli per categoria',
'field_provider' => 'Provider IA',
'field_model' => 'Modello',
'field_ai_limits' => 'Timeout e tentativi',
'timeout_label' => 'Timeout',
'retries_label' => 'Tentativi',
'field_image_instructions' => 'Istruzioni immagine',
'field_image_caption' => 'Didascalia configurabile',
'other_version' => 'Versione del plugin',
'other_support' => 'Supporto e crediti',
'save_all' => 'Salva tutte le impostazioni',
'discard_changes' => 'Annulla modifiche',
'unsaved_changes' => 'Modifiche non salvate',
'exec_subtitle' => 'Segui le esecuzioni e controlla la generazione automatica.',
'running_now' => 'In esecuzione...',
'no_execution' => 'Nessun processo in esecuzione',
'started_in' => 'Avviato: %s',
'idle_desc' => 'Nessuna esecuzione in corso.',
'last_run' => 'Ultima:',
'never' => 'mai',
'running_btn' => 'In esecuzione...',
'abort_btn' => 'Interrompi esecuzione',
'status_running' => 'In esecuzione',
'status_idle' => 'Fermo',
'section_run_now' => 'Esegui ora',
'card_auto' => 'Generazione automatica',
'auto_status_label' => 'Stato',
'auto_active' => 'Attiva',
'auto_inactive' => 'Inattiva',
'next_run' => 'Prossima:',
'auto_first_run' => 'Prima esecuzione',
'start_now' => 'Avvia ora',
'set_schedule' => 'Imposta un orario',
'field_auto_period' => 'Periodo di generazione automatica',
'stop_auto_btn' => 'Metti in pausa',
'start_auto_btn' => 'Avvia automatico',
'log_title' => 'Log in tempo reale',
'log_panel_subtitle' => 'Segui eventi ed errori del plugin.',
'log_live' => 'Aggiornamento automatico',
'log_level' => 'Livello',
'log_level_all' => 'Tutti i livelli',
'log_loading' => 'Caricamento...',
'log_load_more' => 'Carica altro',
'help_subtitle' => 'Crediti del plugin.',
'created_by' => 'creato da',
'licence_label' => 'Licenza GPLv2',
'help_website' => 'Sito dell’autore',
'plugin_active' => 'Plugin attivo',
'version_label' => 'Versione %s',
'js_run_now' => 'Esegui ora',
'js_settings_saved' => 'Impostazioni salvate.',
'js_comm_error' => 'Comunicazione fallita.',
'help_page_title' => 'Aiuto, Crediti e Donazione',
'section_donate' => 'Sostieni il Progetto',
'section_credits' => 'Crediti',
'section_about' => 'Informazioni sul Plugin',
'donate_btn' => 'Dona con Pix',
'or_label' => 'o',
];
@@ -0,0 +1,201 @@
<?php
defined('ABSPATH') || exit;
/**
* Português (Brasil)
*/
return [
'nav_label' => 'Navegação do plugin',
'tab_execucoes' => 'Execuções',
'tab_configuracoes' => 'Configurações',
'tab_log' => 'Log em tempo real',
'tab_ajuda' => 'Ajuda',
'config_subtitle' => 'Configure todas as opções do plugin organizadas por categoria.',
'config_categories' => 'Categorias de configuração',
'section_general' => 'Geral',
'section_general_desc' => 'Configurações principais do plugin.',
'section_prompts' => 'Prompts e Filtro',
'section_prompts_desc' => 'Perfil editorial, exclusões e instruções de reescrita.',
'section_feeds' => 'Feeds RSS',
'section_feeds_desc' => 'Fontes de notícias por RSS.',
'section_gnews' => 'GNews.io',
'section_gnews_desc' => 'Chave de API e categorias para busca de notícias.',
'section_ai' => 'Inteligência Artificial',
'section_ai_desc' => 'Provedor, modelo e parâmetros da IA.',
'section_other' => 'Outras Configurações',
'section_other_desc' => 'Versão e suporte.',
'field_pub_mode' => 'Modo de publicação',
'pub_mode_draft_label' => 'Salvar como rascunho',
'pub_mode_pub_label' => 'Publicar automaticamente',
'pub_mode_desc' => 'Aplica-se tanto ao botão manual quanto à geração automática.',
'field_language' => 'Idioma dos posts',
'language_desc' => 'Define o idioma em que a IA escreve os textos.',
'field_app_language' => 'Idioma do aplicativo',
'app_language_desc' => 'Idioma do painel de administração.',
'field_posts_per_run' => 'Posts por execução',
'min_posts' => 'Mínimo:',
'max_posts' => 'Máximo:',
'field_editorial' => 'Perfil editorial',
'editorial_ph' => 'Ex: Blog de tecnologia, IA e startups. Priorize notícias sobre IA generativa.',
'editorial_desc' => 'A IA usa este perfil para escolher os artigos mais relevantes.',
'field_negative' => 'Perfil negativo',
'negative_ph' => 'Ex: Evite política, futebol e conteúdo sensacionalista.',
'negative_desc' => 'Temas que a IA deve <strong>evitar</strong> ao selecionar artigos.',
'rewrite_label' => 'Reescrita',
'image_label' => 'Imagem de capa',
'field_rewrite' => 'Instruções de reescrita',
'rewrite_ph' => 'Ex: Escreva em tom formal, use dados e evite clickbait.',
'rewrite_desc' => 'Instruções extras enviadas à IA ao reescrever o artigo.',
'field_feeds' => 'URLs dos feeds',
'feeds_desc' => 'Um feed por linha.',
'field_feed_max' => 'Artigos por feed',
'feed_max_desc' => 'Quantos artigos recentes de cada feed serão avaliados.',
'field_gnews_key' => 'Chave de API',
'key_saved_hint' => 'Uma chave já está salva. Deixe o campo em branco para mantê-la.',
'gnews_key_desc' => 'Obtenha em <a href="https://gnews.io" target="_blank" rel="noopener">gnews.io</a>.',
'field_gnews_cats' => 'Categorias',
'gnews_cats_desc' => 'Uma requisição separada é feita para cada categoria selecionada.',
'field_gnews_max' => 'Artigos por categoria',
'gnews_max_desc' => 'No plano gratuito do GNews o máximo é 10 artigos por requisição.',
'field_provider' => 'Provedor de IA',
'provider_desc' => 'O provedor usado em todas as chamadas de IA.',
'field_nvidia_key' => 'Chave de API NVIDIA',
'nvidia_key_desc' => 'Obtenha em <a href="https://build.nvidia.com" target="_blank" rel="noopener">build.nvidia.com</a>.',
'field_openai_key' => 'Chave de API OpenAI',
'openai_key_desc' => 'Obtenha em <a href="https://platform.openai.com" target="_blank" rel="noopener">platform.openai.com</a>.',
'field_anthropic_key' => 'Chave de API Anthropic',
'anthropic_key_desc' => 'Obtenha em <a href="https://console.anthropic.com" target="_blank" rel="noopener">console.anthropic.com</a>.',
'field_openrouter_key' => 'Chave de API OpenRouter',
'openrouter_key_desc' => 'Obtenha em <a href="https://openrouter.ai" target="_blank" rel="noopener">openrouter.ai</a>.',
'field_custom_url' => 'URL base',
'custom_url_desc' => 'Endpoint compatível com OpenAI, incluindo o /v1.',
'field_custom_key' => 'Chave de API',
'custom_key_desc' => 'Chave enviada como Bearer para o endpoint acima.',
'field_vertex_project' => 'Projeto (Project ID)',
'field_vertex_location' => 'Região',
'field_vertex_sa' => 'Service account (JSON)',
'vertex_sa_desc' => 'Conteúdo completo do arquivo JSON da service account do Google Cloud.',
'field_model' => 'Modelo',
'model_desc' => 'Deixe em branco para usar o modelo padrão do provedor.',
'field_ai_limits' => 'Timeout e tentativas',
'timeout_label' => 'Timeout',
'retries_label' => 'Tentativas',
'ai_limits_desc' => 'Aplica-se a todas as chamadas de IA.',
'ai_test_label' => 'Testar conexão',
'ai_test_btn' => 'Testar agora',
'ai_test_desc' => 'Envia uma chamada curta ao provedor selecionado e mostra a resposta ou o erro.',
'ai_test_testing' => 'Testando…',
'ai_test_ok' => 'Conexão funcionando',
'field_image_instructions' => 'Instruções de imagem',
'image_instructions_ph' => 'Ex: Prefira fotos reais de pessoas trabalhando, sem texto na imagem.',
'image_instructions_desc' => 'A IA usa estas instruções para montar a busca no DuckDuckGo e escrever os textos da imagem.',
'field_image_caption' => 'Legenda configurável',
'image_caption_ph' => 'Ex: Sempre cite o nome da empresa. Máximo 120 caracteres.',
'image_caption_desc' => 'Estilo ou regras que a legenda da imagem deve seguir.',
'other_version' => 'Versão do plugin',
'other_support' => 'Suporte e créditos',
'save_all' => 'Salvar todas as configurações',
'discard_changes' => 'Descartar alterações',
'unsaved_changes' => 'Alterações não salvas',
'exec_subtitle' => 'Acompanhe as execuções do plugin e controle a geração automática.',
'running_now' => 'Executando agora...',
'no_execution' => 'Nenhum processo em execução',
'started_in' => 'Iniciado em: %s',
'idle_desc' => 'Nenhuma execução em andamento no momento.',
'processing_feeds' => 'Processando feeds...',
'last_run' => 'Última:',
'never' => 'nunca',
'running_btn' => 'Em execução...',
'abort_btn' => 'Abortar execução',
'exec_note' => 'Atualizado automaticamente a cada 3 segundos. O abort encerra o processo imediatamente.',
'status_running' => 'Em execução',
'status_idle' => 'Parado',
'section_run_now' => 'Executar agora',
'card_auto' => 'Geração automática',
'auto_status_label' => 'Status',
'auto_active' => 'Ativa',
'auto_inactive' => 'Inativa',
'next_run' => 'Próxima:',
'auto_first_run' => 'Primeira execução',
'start_now' => 'Iniciar agora',
'set_schedule' => 'Definir um horário',
'schedule_desc' => 'Escolha data e hora para a primeira execução.',
'field_auto_period' => 'Período de geração automática',
'auto_period_hint' => 'Intervalo entre execuções automáticas.',
'stop_auto_btn' => 'Pausar automático',
'start_auto_btn' => 'Iniciar automático',
'log_title' => 'Log em tempo real',
'log_panel_subtitle' => 'Acompanhe os eventos e erros do plugin.',
'log_live' => 'Atualização automática',
'log_level' => 'Nível',
'log_level_all' => 'Todos os níveis',
'log_loading' => 'Carregando...',
'log_load_more' => 'Carregar mais',
'help_subtitle' => 'Créditos do plugin.',
'created_by' => 'criado por',
'licence_label' => 'Licença GPLv2',
'help_website' => 'Site do autor',
'plugin_active' => 'Plugin ativo',
'version_label' => 'Versão %s',
'js_no_execution' => 'Nenhum processo em execução',
'js_running' => 'Em execução...',
'js_aborting' => 'Abortando...',
'js_run_now' => 'Executar agora',
'js_processing' => 'Processando feeds...',
'js_error_prefix' => 'Erro:',
'js_aborted' => 'Execução abortada.',
'js_wait' => 'Aguarde...',
'js_auto_started' => 'Geração automática ativada.',
'js_auto_paused' => 'Geração automática pausada.',
'js_comm_error' => 'Falha de comunicação.',
'js_error_start' => 'Não foi possível iniciar.',
'js_error_abort' => 'Não foi possível abortar.',
'js_start_auto' => 'Iniciar automático',
'js_stop_auto' => 'Pausar automático',
'js_log_empty' => 'Nenhum log registrado ainda.',
'js_log_updated' => 'Atualizado',
'js_next_run' => 'Próxima:',
'js_auto_active' => 'Ativa',
'js_auto_inactive' => 'Inativa',
'js_schedule_required' => 'Escolha data e hora.',
'js_schedule_past' => 'A data precisa estar no futuro.',
'js_settings_saved' => 'Configurações salvas.',
'js_preparing' => 'Preparando...',
'js_running_feeds' => 'Processando feeds...',
'js_sleeping' => 'Aguardando...',
'gnews_categories' => ['general' => 'Geral', 'world' => 'Mundo', 'nation' => 'Nacional', 'business' => 'Negócios', 'technology' => 'Tecnologia', 'entertainment' => 'Entretenimento', 'sports' => 'Esportes', 'science' => 'Ciência', 'health' => 'Saúde'],
'auto_period_units' => ['minutes' => 'minutos', 'hours' => 'horas', 'days' => 'dias'],
'help_page_title' => 'Ajuda, Créditos e Doação',
'help_page_subtitle' => 'Encontre ajuda, veja os créditos do plugin e contribua com o projeto.',
'section_donate' => 'Contribua com o Projeto',
'section_donate_desc' => 'Seu apoio nos ajuda a manter o plugin atualizado e continuar evoluindo.',
'donate_title' => 'Faça uma doação',
'donate_desc' => 'Qualquer valor ajuda muito a manter o desenvolvimento deste plugin e trazer novas funcionalidades para você.',
'donate_btn' => 'Doar agora com Pix',
'donate_copied' => 'Chave copia e cola copiada!',
'donate_qr_hint' => 'Aponte sua câmera',
'or_label' => 'ou',
'section_credits' => 'Créditos',
'section_credits_desc' => 'Agradecemos a todos que tornam este projeto possível.',
'credit_developer' => 'Desenvolvedor Principal',
'section_about' => 'Sobre o Plugin',
'section_about_desc' => 'Informações importantes sobre o Automatic AI Posts.',
'about_desc' => 'Plugin WordPress para gerar e publicar posts automaticamente com inteligência artificial, de forma simples e eficiente.',
'about_feature_1' => 'Posts automáticos com IA',
'about_feature_2' => 'Múltiplas configurações',
'about_feature_3' => 'Logs detalhados',
'about_feature_4' => 'Suporte a feeds RSS',
'about_feature_5' => 'Execução agendada',
'about_feature_6' => 'Provedores de IA variados',
'about_github' => 'Projeto no GitHub',
'donate_key_label' => 'Chave copia e cola:',
'donate_no_qr' => 'Salve a imagem do QR code em admin/pix-qrcode.png',
'editor_box_title' => 'Automatic AI Posts',
'editor_desc' => 'Busca uma nova imagem de capa e regenera o alt, a legenda e a descrição, exatamente como o pipeline.',
'editor_btn_has_image' => 'Buscar nova imagem',
'editor_btn_no_image' => 'Buscar imagem de capa',
'editor_working' => 'Buscando uma nova imagem…',
'editor_success' => 'Imagem de capa atualizada.',
'editor_no_image' => 'Nenhuma imagem utilizável foi encontrada. A capa atual foi mantida.',
'editor_error' => 'Não foi possível buscar a imagem. Consulte os logs do plugin.',
];
@@ -0,0 +1,30 @@
<?php
namespace AAP\Core;
defined('ABSPATH') || exit;
/**
* PSR-4 style autoloader: AAP\Foo\Bar -> src/Foo/Bar.php
*/
final class Autoloader {
private static string $base = '';
public static function register(string $base): void {
self::$base = rtrim($base, '/') . '/';
spl_autoload_register([self::class, 'load']);
}
public static function load(string $class): void {
if (strncmp($class, 'AAP\\', 4) !== 0) {
return;
}
$relative = substr($class, 4);
$path = self::$base . str_replace('\\', '/', $relative) . '.php';
if (is_readable($path)) {
require_once $path;
}
}
}
@@ -0,0 +1,30 @@
<?php
namespace AAP\Core;
defined('ABSPATH') || exit;
/**
* A module is a self-contained unit of the plugin.
*
* Lifecycle:
* register() — attach hooks that must exist before boot (rarely needed).
* boot() — attach runtime hooks; dependencies are already booted here.
*/
abstract class Module {
/** Unique module id (used by dependencies()). */
abstract public function id(): string;
/** @return string[] ids of modules that must boot first. */
public function dependencies(): array {
return [];
}
/** Called before any boot(), in dependency order. */
public function register(): void {
}
/** Called after every module was registered, in dependency order. */
public function boot(): void {
}
}
@@ -0,0 +1,97 @@
<?php
namespace AAP\Core;
defined('ABSPATH') || exit;
/**
* Central place for every option the plugin stores.
*
* IMPORTANT: existing option names are kept exactly as they were in 1.x so
* upgrading never loses configuration.
*/
final class Options {
public const DEFAULTS = [
// Feeds
'aap_feeds' => "https://feeds.bbci.co.uk/news/rss.xml\nhttps://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml",
'aap_feed_max_items' => 10,
// GNews
'aap_gnews_api_key' => '',
'aap_gnews_categories' => [],
'aap_gnews_max' => 10,
// Generation
'aap_min_posts' => 1,
'aap_max_posts' => 3,
'aap_post_status' => 'draft',
'aap_post_language' => 'pt-BR',
// Editorial instructions
'aap_relevance_description' => '',
'aap_negative_profile' => '',
'aap_rewrite_instructions' => '',
// AI provider
'aap_ai_provider' => 'nvidia',
'aap_nvidia_api_key' => '',
'aap_openai_api_key' => '',
'aap_anthropic_api_key' => '',
'aap_openrouter_api_key' => '',
'aap_custom_api_key' => '',
'aap_custom_base_url' => '',
'aap_vertex_project' => '',
'aap_vertex_location' => 'us-central1',
'aap_vertex_service_account'=> '',
'aap_model' => '',
'aap_ai_timeout' => 120,
'aap_ai_max_retries' => 3,
// Images
'aap_image_instructions' => '',
'aap_image_caption' => '',
// Scheduling
'aap_auto_interval_value' => 24,
'aap_auto_interval_unit' => 'hours',
'aap_auto_enabled' => false,
'aap_auto_user_id' => 0,
'aap_next_auto_run' => 0,
'aap_last_run' => '',
// App
'aap_app_language' => 'pt_BR',
];
public static function get(string $key, $fallback = null) {
$default = self::DEFAULTS[$key] ?? $fallback;
$value = get_option($key, $default);
return $value === null ? $default : $value;
}
public static function set(string $key, $value): bool {
return update_option($key, $value);
}
public static function bool(string $key): bool {
return (bool) self::get($key);
}
public static function int(string $key): int {
return (int) self::get($key);
}
public static function string(string $key): string {
return (string) self::get($key);
}
/** Creates missing options without touching existing ones. */
public static function install_defaults(): void {
foreach (self::DEFAULTS as $key => $value) {
if (get_option($key, null) === null) {
add_option($key, $value);
}
}
}
}
@@ -0,0 +1,88 @@
<?php
namespace AAP\Core;
defined('ABSPATH') || exit;
/**
* Resolves the module dependency tree and boots it in order.
*/
final class Registry {
/** @var array<string, Module> */
private static array $modules = [];
private static bool $booted = false;
public static function add(Module $module): void {
self::$modules[$module->id()] = $module;
}
public static function get(string $id): ?Module {
return self::$modules[$id] ?? null;
}
/** @return array<string, Module> */
public static function all(): array {
return self::$modules;
}
public static function boot(): void {
if (self::$booted) {
return;
}
self::$booted = true;
$order = self::resolve();
foreach ($order as $id) {
self::$modules[$id]->register();
}
foreach ($order as $id) {
self::$modules[$id]->boot();
}
}
/**
* Topological sort. Missing dependencies are skipped with a notice
* (a broken module must not take the whole admin down).
*
* @return string[]
*/
private static function resolve(): array {
$resolved = [];
$visiting = [];
$visit = function (string $id) use (&$visit, &$resolved, &$visiting): void {
if (isset($resolved[$id]) || isset($visiting[$id])) {
return;
}
$module = self::$modules[$id] ?? null;
if (!$module) {
return;
}
$visiting[$id] = true;
foreach ($module->dependencies() as $dependency) {
if (!isset(self::$modules[$dependency])) {
if (function_exists('error_log')) {
error_log("[Automatic AI Posts] Module \"{$id}\" requires missing module \"{$dependency}\".");
}
continue;
}
$visit($dependency);
}
unset($visiting[$id]);
$resolved[$id] = true;
};
foreach (array_keys(self::$modules) as $id) {
$visit($id);
}
return array_keys($resolved);
}
}
@@ -0,0 +1,257 @@
<?php
namespace AAP\Modules\Admin;
use AAP\Core\Module;
use AAP\Core\Options;
use AAP\Modules\Ai\ProviderFactory;
use AAP\Modules\I18n\Lang;
use AAP\Modules\Pipeline\RunState;
use AAP\Support\Languages;
defined('ABSPATH') || exit;
/**
* Wires the admin page, assets, settings registration and AJAX.
*/
final class AdminModule extends Module {
private SettingsPage $page;
public function id(): string {
return 'admin';
}
public function dependencies(): array {
return ['logs', 'i18n', 'ai', 'feeds', 'content', 'images', 'posts', 'pipeline', 'schedule'];
}
public function boot(): void {
$this->page = new SettingsPage();
add_action('admin_menu', [$this, 'menu']);
add_action('admin_enqueue_scripts', [$this, 'assets']);
add_action('admin_init', [$this, 'register_settings']);
add_filter('wp_redirect', [$this->page, 'keep_tab_on_redirect']);
(new Ajax($this->page))->register();
add_filter('plugin_row_meta', [$this, 'row_meta'], 10, 2);
}
public function row_meta(array $links, string $file): array {
if (plugin_basename(AAP_PLUGIN_FILE) !== $file) {
return $links;
}
array_unshift($links, '<strong>Automatic AI Posts</strong> v' . AAP_VERSION);
return $links;
}
public function menu(): void {
$svg = (string) file_get_contents(AAP_PLUGIN_DIR . 'admin/icon.svg');
add_menu_page(
'Automatic AI Posts',
'Automatic AI Posts',
'manage_options',
'automatic-ai-posts',
[$this->page, 'render'],
'data:image/svg+xml;base64,' . base64_encode($svg),
80
);
}
/**
* Cache busting for the bundled admin assets.
*
* Uses AAP_VERSION plus the file mtime so browsers pick up an edited
* admin.js/admin.css even when the plugin version string did not change,
* which would otherwise leave a stale script cached forever.
*/
private function asset_version(string $relative): string {
$path = AAP_PLUGIN_DIR . $relative;
return AAP_VERSION . '.' . (file_exists($path) ? (string) filemtime($path) : '0');
}
public function assets(string $hook): void {
if ($hook !== 'toplevel_page_automatic-ai-posts') {
return;
}
wp_enqueue_style('aap-admin', AAP_PLUGIN_URL . 'admin/admin.css', [], $this->asset_version('admin/admin.css'));
wp_enqueue_script('aap-admin', AAP_PLUGIN_URL . 'admin/admin.js', ['jquery'], $this->asset_version('admin/admin.js'), true);
$started_at = RunState::started_at();
$next = (int) get_option('aap_next_auto_run', 0);
wp_localize_script('aap-admin', 'aapData', [
'tab' => $this->page->current_tab(),
'ajaxurl' => admin_url('admin-ajax.php'),
'options_url' => admin_url('options.php'),
'nonce_start' => wp_create_nonce('aap_start_pipeline'),
'nonce_status' => wp_create_nonce('aap_check_status'),
'nonce_toggle_auto' => wp_create_nonce('aap_toggle_auto'),
'nonce_logs' => wp_create_nonce('aap_fetch_logs'),
'nonce_abort' => wp_create_nonce('aap_abort_execution'),
'processing' => RunState::is_running(),
'started_at' => $started_at,
'started_label' => $started_at > 0
? sprintf(Lang::t('started_in'), wp_date('d/m/Y H:i:s', $started_at))
: Lang::t('running_now'),
'idle_desc' => Lang::t('idle_desc'),
'interval_value' => (string) Options::get('aap_auto_interval_value', 24),
'interval_unit' => (string) Options::get('aap_auto_interval_unit', 'hours'),
'auto_enabled' => Options::bool('aap_auto_enabled'),
'next_run' => $next > 0 ? wp_date('d/m/Y H:i', $next) : '',
'log_per_page' => 50,
'provider_defaults' => ProviderFactory::DEFAULT_MODELS,
's' => [
'no_execution' => Lang::t('js_no_execution'),
'running' => Lang::t('js_running'),
'aborting' => Lang::t('js_aborting'),
'run_now' => Lang::t('js_run_now'),
'processing' => Lang::t('js_processing'),
'error_prefix' => Lang::t('js_error_prefix'),
'aborted' => Lang::t('js_aborted'),
'wait' => Lang::t('js_wait'),
'auto_started' => Lang::t('js_auto_started'),
'auto_paused' => Lang::t('js_auto_paused'),
'comm_error' => Lang::t('js_comm_error'),
'error_start' => Lang::t('js_error_start'),
'error_abort' => Lang::t('js_error_abort'),
'start_auto' => Lang::t('js_start_auto'),
'stop_auto' => Lang::t('js_stop_auto'),
'log_empty' => Lang::t('js_log_empty'),
'log_updated' => Lang::t('js_log_updated'),
'next_run' => Lang::t('js_next_run'),
'auto_active' => Lang::t('js_auto_active'),
'auto_inactive' => Lang::t('js_auto_inactive'),
'schedule_required' => Lang::t('js_schedule_required'),
'schedule_past' => Lang::t('js_schedule_past'),
'settings_saved' => Lang::t('js_settings_saved'),
'preparing' => Lang::t('js_preparing'),
'running_feeds' => Lang::t('js_running_feeds'),
'sleeping' => Lang::t('js_sleeping'),
'status_running' => Lang::t('status_running'),
'status_idle' => Lang::t('status_idle'),
'log_load_more' => Lang::t('log_load_more'),
'log_level_all' => Lang::t('log_level_all'),
'processing_feeds' => Lang::t('processing_feeds'),
'last_run_prefix' => Lang::t('last_run'),
'pix_copied' => Lang::t('donate_copied'),
],
'ai_test' => [
'testing' => Lang::t('ai_test_testing'),
'ok' => Lang::t('ai_test_ok'),
'error' => Lang::t('js_comm_error'),
],
]);
}
public function register_settings(): void {
$text = 'sanitize_text_field';
$textarea = 'sanitize_textarea_field';
$absint = 'absint';
$settings = [
'aap_feeds' => [$this, 'sanitize_feeds'],
'aap_feed_max_items' => $absint,
'aap_gnews_api_key' => $this->api_key('aap_gnews_api_key'),
'aap_gnews_categories' => [$this, 'sanitize_gnews_categories'],
'aap_gnews_max' => $absint,
'aap_min_posts' => $absint,
'aap_max_posts' => $absint,
'aap_post_status' => $this->enum(['draft', 'publish']),
'aap_post_language' => $this->enum(array_keys(Languages::POST)),
'aap_app_language' => $this->enum(array_keys(Languages::APP)),
'aap_relevance_description' => $textarea,
'aap_negative_profile' => $textarea,
'aap_rewrite_instructions' => $textarea,
'aap_image_instructions' => $textarea,
'aap_image_caption' => $textarea,
'aap_ai_provider' => $this->enum(array_keys(ProviderFactory::LABELS)),
'aap_nvidia_api_key' => $this->api_key('aap_nvidia_api_key'),
'aap_openai_api_key' => $this->api_key('aap_openai_api_key'),
'aap_anthropic_api_key' => $this->api_key('aap_anthropic_api_key'),
'aap_openrouter_api_key' => $this->api_key('aap_openrouter_api_key'),
'aap_custom_api_key' => $this->api_key('aap_custom_api_key'),
'aap_custom_base_url' => 'esc_url_raw',
'aap_vertex_project' => $text,
'aap_vertex_location' => $text,
'aap_vertex_service_account'=> [$this, 'sanitize_json'],
'aap_model' => $text,
'aap_ai_timeout' => $absint,
'aap_ai_max_retries' => $absint,
'aap_auto_interval_value' => $absint,
'aap_auto_interval_unit' => $this->enum(['minutes', 'hours', 'days']),
];
foreach ($settings as $key => $callback) {
register_setting('aap_settings', $key, [
'sanitize_callback' => $callback,
'show_in_rest' => false,
]);
}
}
/** @param mixed $value */
public function sanitize_feeds($value): string {
$lines = array_filter(array_map('trim', explode("\n", (string) $value)));
return implode("\n", array_map('esc_url_raw', $lines));
}
/** @param mixed $value */
public function sanitize_json($value): string {
$value = trim((string) $value);
if ($value === '') {
return '';
}
json_decode($value);
return json_last_error() === JSON_ERROR_NONE ? $value : '';
}
/** @param mixed $value */
public function sanitize_gnews_categories($value): array {
if (!is_array($value)) {
return [];
}
$valid = array_keys(\AAP\Modules\Feeds\GnewsFetcher::CATEGORIES);
return array_values(array_filter($value, static fn($item) => in_array($item, $valid, true)));
}
/**
* API keys never travel back to the browser: an empty field keeps the
* stored value. A request may still pass "<option>_clear=1" to wipe the
* key, which keeps programmatic resets working now that the settings form
* no longer shows a "remove saved key" checkbox.
*/
private function api_key(string $option): callable {
return static function ($value) use ($option) {
if (isset($_POST[$option . '_clear']) && '1' === (string) wp_unslash($_POST[$option . '_clear'])) {
return '';
}
$value = trim((string) $value);
return $value === '' ? (string) get_option($option, '') : sanitize_text_field($value);
};
}
/** @param string[] $allowed */
private function enum(array $allowed): callable {
return static function ($value) use ($allowed) {
$value = sanitize_text_field((string) $value);
return in_array($value, $allowed, true) ? $value : ($allowed[0] ?? '');
};
}
}
@@ -0,0 +1,176 @@
<?php
namespace AAP\Modules\Admin;
use AAP\Core\Options;
use AAP\Modules\Logs\LogRepository;
use AAP\Modules\Logs\Logger;
use AAP\Modules\Pipeline\PipelineModule;
use AAP\Modules\Pipeline\RunState;
use AAP\Modules\Schedule\Scheduler;
defined('ABSPATH') || exit;
/**
* AJAX endpoints. Action names are kept from 1.x so the UI keeps working.
*/
final class Ajax {
private SettingsPage $page;
public function __construct(SettingsPage $page) {
$this->page = $page;
}
public function register(): void {
add_action('wp_ajax_aap_start_pipeline', [$this, 'start_pipeline']);
add_action('wp_ajax_aap_check_status', [$this, 'check_status']);
add_action('wp_ajax_aap_toggle_auto', [$this, 'toggle_auto']);
add_action('wp_ajax_aap_fetch_logs', [$this, 'fetch_logs']);
add_action('wp_ajax_aap_abort_execution', [$this, 'abort_execution']);
add_action('wp_ajax_aap_test_ai', [$this, 'test_ai']);
}
private function guard(string $nonce): void {
check_ajax_referer($nonce, 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => 'forbidden'], 403);
}
}
public function start_pipeline(): void {
$this->guard('aap_start_pipeline');
if (RunState::is_running()) {
if (!RunState::is_stale()) {
wp_send_json_error(['message' => 'already_running']);
}
Logger::warn('Stale run detected; clearing state before a new run.', 'pipeline');
RunState::stop();
}
$module = \AAP\Core\Registry::get('pipeline');
if (!$module instanceof PipelineModule) {
wp_send_json_error(['message' => 'pipeline_unavailable']);
}
if (!$module->spawn(get_current_user_id())) {
wp_send_json_error(['message' => 'cannot_start']);
}
wp_send_json_success();
}
public function check_status(): void {
$this->guard('aap_check_status');
$next = (int) get_option('aap_next_auto_run', 0);
wp_send_json_success([
'processing' => RunState::is_running(),
'abort_requested' => (bool) get_transient('aap_abort_requested'),
'auto_enabled' => Options::bool('aap_auto_enabled'),
'next_run' => $next > 0 ? wp_date('d/m/Y H:i', $next) : '',
'last_run' => (string) Options::get('aap_last_run', ''),
]);
}
public function toggle_auto(): void {
$this->guard('aap_toggle_auto');
$enable = filter_var($_POST['enable'] ?? false, FILTER_VALIDATE_BOOLEAN);
$mode = sanitize_text_field(wp_unslash($_POST['mode'] ?? 'now'));
$scheduled_at = (int) ($_POST['scheduled_at'] ?? 0);
if (isset($_POST['interval_value'])) {
update_option('aap_auto_interval_value', max(1, absint($_POST['interval_value'])));
}
if (isset($_POST['interval_unit'])) {
$unit = sanitize_text_field(wp_unslash($_POST['interval_unit']));
if (in_array($unit, ['minutes', 'hours', 'days'], true)) {
update_option('aap_auto_interval_unit', $unit);
}
}
$scheduler = new Scheduler();
if ($enable) {
$scheduler->start($mode, get_current_user_id(), $scheduled_at);
} else {
$scheduler->stop();
}
$next = (int) get_option('aap_next_auto_run', 0);
wp_send_json_success([
'enabled' => $enable,
'next_run' => $next > 0 ? wp_date('d/m/Y H:i', $next) : '',
'last_run' => (string) Options::get('aap_last_run', ''),
]);
}
public function fetch_logs(): void {
$this->guard('aap_fetch_logs');
$page = max(1, (int) ($_POST['page'] ?? 1));
$per_page = min(100, max(10, (int) ($_POST['per_page'] ?? 50)));
$level = sanitize_key(wp_unslash($_POST['level'] ?? ''));
$result = LogRepository::page($page, $per_page, $level);
$rows = array_map(static function ($row): array {
return [
'id' => (int) $row->id,
'time' => mysql2date('d/m/Y H:i:s', $row->created_at),
'level' => $row->level,
'message' => $row->message,
'context' => (string) $row->context,
];
}, $result['rows']);
wp_send_json_success([
'rows' => $rows,
'page' => $page,
'total' => $result['total'],
'has_more' => $result['has_more'],
]);
}
public function test_ai(): void {
$this->guard('aap_test_ai');
$result = (new \AAP\Modules\Ai\AiService())->test();
if ($result['ok']) {
wp_send_json_success($result);
}
wp_send_json_error($result);
}
public function abort_execution(): void { $this->guard('aap_abort_execution');
$pid = (int) get_option('aap_pipeline_pid', 0);
$killed = false;
if ($pid > 0) {
if (function_exists('posix_kill')) {
posix_kill($pid, 9);
$killed = true;
} else {
shell_exec('kill -9 ' . $pid . ' 2>/dev/null');
$killed = true;
}
}
// stop() clears the transient, so the abort flag is set afterwards.
RunState::stop();
RunState::request_abort();
Logger::warn('Run aborted by the user' . ($killed ? " (PID {$pid} killed)" : '') . '.', 'pipeline');
wp_send_json_success(['killed' => $killed, 'pid' => $pid]);
}
}
@@ -0,0 +1,155 @@
<?php
namespace AAP\Modules\Admin\Panels;
use AAP\Core\Options;
use AAP\Modules\Admin\SettingsPage;
use AAP\Modules\I18n\Lang;
use AAP\Support\Icons;
defined('ABSPATH') || exit;
/**
* Ajuda tab: donation, credits and about — stacked in a single column.
*/
final class HelpPanel {
/**
* EDITE AQUI — doação via Pix.
*
* PIX_KEY: chave Pix "copia e cola" (payload EMV do Banco Central).
* É este o texto que o botão copia para a área de transferência.
* PIX_QR: caminho da imagem do QR code dentro do plugin
* (basta salvar o arquivo em admin/pix-qrcode.png).
*/
private const PIX_KEY = '00020126580014BR.GOV.BCB.PIX0136d39969f0-0cfa-431e-8ad0-64d845c6ef0b5204000053039865802BR5925GUSTAVO OLIVEIRA DA SILVA6009SAO PAULO622605222dNkUW7gsvMExFSFt20n09630467BC';
private const PIX_QR = 'admin/pix-qrcode.png';
private SettingsPage $page;
public function __construct(SettingsPage $page) {
$this->page = $page;
}
public function render(): void {
$pix = trim(self::PIX_KEY);
$qr_src = AAP_PLUGIN_URL . self::PIX_QR;
$has_qr = is_readable(AAP_PLUGIN_DIR . self::PIX_QR);
?>
<section class="aap-panel" id="aap-panel-ajuda" aria-labelledby="aap-h-ajuda">
<?php $this->page->panel_head('help', 'aap-h-ajuda', Lang::t('help_page_title'), Lang::t('help_page_subtitle')); ?>
<div class="aap-panel-body">
<div class="aap-help-stack">
<?php /* ── Contribua com o Projeto ── */ ?>
<section class="aap-help-card">
<div class="aap-help-card-head">
<span class="aap-help-ico is-red"><?php echo Icons::svg('heart'); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
<div class="aap-help-card-text">
<h3><?php echo esc_html(Lang::t('section_donate')); ?></h3>
<p><?php echo esc_html(Lang::t('section_donate_desc')); ?></p>
</div>
</div>
<div class="aap-donate-box">
<div class="aap-donate-text">
<div class="aap-donate-head">
<span class="aap-help-ico is-violet sm"><?php echo Icons::svg('star'); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
<h4><?php echo esc_html(Lang::t('donate_title')); ?></h4>
</div>
<p><?php echo esc_html(Lang::t('donate_desc')); ?></p>
<button type="button" class="aap-btn aap-btn-primary aap-pix-btn" id="aap-pix-btn"
data-pix="<?php echo esc_attr($pix); ?>" <?php disabled($pix === ''); ?>>
<?php echo Icons::svg('pix'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span><?php echo esc_html(Lang::t('donate_btn')); ?></span>
</button>
<?php if ($pix !== '') : ?>
<span class="aap-pix-key">
<?php echo esc_html(Lang::t('donate_key_label')); ?>
<code><?php echo esc_html($pix); ?></code>
</span>
<?php endif; ?>
<span id="aap-pix-feedback" class="aap-feedback" role="status" aria-live="polite"></span>
</div>
<div class="aap-donate-qr">
<?php if ($has_qr) : ?>
<img src="<?php echo esc_url($qr_src); ?>"
alt="<?php echo esc_attr(Lang::t('donate_btn')); ?>" width="150" height="150" loading="lazy">
<span><?php echo esc_html(Lang::t('donate_qr_hint')); ?></span>
<?php else : ?>
<div class="aap-qr-empty">
<?php echo Icons::svg('pix'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span><?php echo esc_html(Lang::t('donate_no_qr')); ?></span>
</div>
<?php endif; ?>
</div>
</div>
</section>
<?php /* ── Créditos ── */ ?>
<section class="aap-help-card">
<div class="aap-help-card-head">
<span class="aap-help-ico is-amber"><?php echo Icons::svg('star'); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
<div class="aap-help-card-text">
<h3><?php echo esc_html(Lang::t('section_credits')); ?></h3>
<p><?php echo esc_html(Lang::t('section_credits_desc')); ?></p>
</div>
</div>
<div class="aap-credit-row">
<span class="aap-credit-avatar"><?php echo Icons::logo(); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
<div class="aap-credit-text">
<strong><?php echo esc_html(Lang::t('credit_developer')); ?></strong>
<span class="aap-credit-name">Gustavo Oliveira da Silva</span>
<a href="https://gustavoo.me/" target="_blank" rel="noopener">https://gustavoo.me/</a>
</div>
</div>
</section>
<?php /* ── Sobre o Plugin ── */ ?>
<section class="aap-help-card">
<div class="aap-help-card-head">
<span class="aap-help-ico is-amber"><?php echo Icons::svg('bolt'); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
<div class="aap-help-card-text">
<h3><?php echo esc_html(Lang::t('section_about')); ?></h3>
<p><?php echo esc_html(Lang::t('section_about_desc')); ?></p>
</div>
</div>
<div class="aap-about-box">
<span class="aap-about-icon"><?php echo Icons::logo(); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
<div class="aap-about-text">
<h4>Automatic AI Posts</h4>
<p><?php echo esc_html(Lang::t('about_desc')); ?></p>
</div>
<span class="aap-version-pill"><?php echo esc_html(sprintf(Lang::t('version_label'), AAP_VERSION)); ?></span>
</div>
<ul class="aap-about-features">
<?php for ($i = 1; $i <= 6; $i++) : ?>
<li>
<span class="aap-check-dot"><?php echo Icons::svg('check'); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
<?php echo esc_html(Lang::t('about_feature_' . $i)); ?>
</li>
<?php endfor; ?>
</ul>
<div class="aap-help-links">
<a href="https://github.com/gustavoo-me" target="_blank" rel="noopener">
<?php echo Icons::svg('code'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span><?php echo esc_html(Lang::t('about_github')); ?></span>
<?php echo Icons::svg('external'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
</a>
</div>
</section>
</div>
</div>
</section>
<?php
}
}
@@ -0,0 +1,58 @@
<?php
namespace AAP\Modules\Admin\Panels;
use AAP\Modules\Admin\SettingsPage;
use AAP\Modules\I18n\Lang;
use AAP\Support\Icons;
defined('ABSPATH') || exit;
/**
* Log tab: lazy loaded, newest first. No "clear" action.
*/
final class LogPanel {
private SettingsPage $page;
public function __construct(SettingsPage $page) {
$this->page = $page;
}
public function render(): void {
$actions = '<div class="aap-actions">'
. '<label class="aap-check"><input type="checkbox" id="aap-log-live" class="aap-pref-live" checked>'
. esc_html(Lang::t('log_live')) . '</label>'
. '</div>';
?>
<section class="aap-panel" id="aap-panel-log" aria-labelledby="aap-h-log">
<?php $this->page->panel_head('list', 'aap-h-log', Lang::t('log_title'), Lang::t('log_panel_subtitle'), $actions); ?>
<div class="aap-panel-body">
<div class="aap-log-shell">
<div class="aap-log-toolbar">
<select id="rap-log-level" aria-label="<?php echo esc_attr(Lang::t('log_level')); ?>">
<option value=""><?php echo esc_html(Lang::t('log_level_all')); ?></option>
<option value="info">INFO</option>
<option value="warn">WARN</option>
<option value="error">ERROR</option>
</select>
<span id="rap-log-status" class="aap-log-status"><?php echo esc_html(Lang::t('log_loading')); ?></span>
</div>
<div id="rap-log-box" class="aap-log-box" role="log" aria-live="off" aria-label="<?php echo esc_attr(Lang::t('log_title')); ?>"></div>
<div class="aap-log-more">
<button type="button" id="rap-log-more" class="aap-btn aap-btn-outline" hidden>
<?php echo Icons::svg('list'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span><?php echo esc_html(Lang::t('log_load_more')); ?></span>
</button>
</div>
</div>
</div>
</section>
<?php
}
}
@@ -0,0 +1,157 @@
<?php
namespace AAP\Modules\Admin\Panels;
use AAP\Core\Options;
use AAP\Modules\Admin\SettingsPage;
use AAP\Modules\I18n\Lang;
use AAP\Modules\Pipeline\RunState;
use AAP\Modules\Schedule\Scheduler;
use AAP\Support\Icons;
defined('ABSPATH') || exit;
/**
* Execuções tab.
*/
final class RunPanel {
private SettingsPage $page;
public function __construct(SettingsPage $page) {
$this->page = $page;
}
public function render(): void {
$processing = RunState::is_running();
$started_at = RunState::started_at();
$started_f = $started_at > 0 ? wp_date('d/m/Y H:i:s', $started_at) : '';
$last_run = (string) Options::get('aap_last_run', '');
$auto_enabled = Options::bool('aap_auto_enabled');
$next = (int) get_option('aap_next_auto_run', 0);
$next_f = $next > 0 ? wp_date('d/m/Y H:i', $next) : '';
$status_title = $processing ? Lang::t('running_now') : Lang::t('no_execution');
$status_sub = $processing && $started_f
? sprintf(Lang::t('started_in'), $started_f)
: Lang::t('idle_desc');
?>
<section class="aap-panel" id="aap-panel-execucoes" aria-labelledby="aap-h-execucoes">
<?php $this->page->panel_head('play', 'aap-h-execucoes', Lang::t('tab_execucoes'), Lang::t('exec_subtitle')); ?>
<div class="aap-panel-body">
<div class="aap-subpanel" id="rap-run-panel">
<div class="aap-subpanel-head">
<?php echo Icons::svg('play', 'aap-lead'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span class="aap-subpanel-title"><?php echo esc_html(Lang::t('section_run_now')); ?></span>
</div>
<div class="aap-subpanel-body">
<div class="aap-exec-status<?php echo $processing ? '' : ' is-idle'; ?>" id="rap-exec-section">
<span class="aap-dot"></span>
<div class="aap-exec-main">
<div class="aap-exec-title" id="rap-exec-text"><?php echo esc_html($status_title); ?></div>
<div class="aap-exec-sub" id="rap-exec-sub"><?php echo esc_html($status_sub); ?></div>
<div class="aap-progress<?php echo $processing ? ' is-indeterminate' : ''; ?>" id="rap-progress">
<div class="aap-progress-bar"></div>
</div>
<div class="aap-progress-foot">
<span id="rap-run-status" class="aap-feedback" role="status" aria-live="polite"><?php echo esc_html(Lang::t('processing_feeds')); ?></span>
<span id="rap-last-run"><?php echo esc_html(Lang::t('last_run')); ?> <strong><?php echo $last_run !== '' ? esc_html($last_run) : esc_html(Lang::t('never')); ?></strong></span>
</div>
</div>
<div class="aap-exec-side">
<button type="button" id="rap-run-now" class="aap-btn aap-btn-primary" <?php disabled($processing); ?>>
<?php echo $processing ? Icons::svg('refresh') : Icons::svg('play'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span><?php echo esc_html($processing ? Lang::t('running_btn') : Lang::t('js_run_now')); ?></span>
</button>
<button type="button" id="rap-abort-btn" class="aap-btn aap-btn-danger-outline" style="<?php echo $processing ? '' : 'display:none;'; ?>">
<?php echo Icons::svg('square'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span><?php echo esc_html(Lang::t('abort_btn')); ?></span>
</button>
<div id="rap-abort-status" class="aap-feedback" role="status" aria-live="polite"></div>
</div>
<p class="aap-exec-note"><?php echo esc_html(Lang::t('exec_note')); ?></p>
</div>
</div>
</div>
<div class="aap-subpanel" id="rap-auto-panel">
<div class="aap-subpanel-head">
<?php echo Icons::svg('clock', 'aap-lead'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span class="aap-subpanel-title"><?php echo esc_html(Lang::t('card_auto')); ?></span>
</div>
<div class="aap-subpanel-body">
<div class="aap-field">
<span class="aap-label"><?php echo esc_html(Lang::t('auto_status_label')); ?></span>
<div class="aap-field-input">
<div class="aap-inline">
<span id="rap-auto-badge" class="aap-radio" style="cursor:default;font-weight:600;">
<span id="rap-auto-badge-dot" class="aap-dot<?php echo $auto_enabled ? ' is-on' : ''; ?>"></span>
<span id="rap-auto-badge-text"><?php echo esc_html($auto_enabled ? Lang::t('auto_active') : Lang::t('auto_inactive')); ?></span>
</span>
<span id="rap-next-run" class="aap-help" style="margin:0;<?php echo ($auto_enabled && $next_f !== '') ? '' : 'display:none;'; ?>">
<?php echo esc_html(Lang::t('next_run')); ?> <strong><?php echo esc_html($next_f); ?></strong>
</span>
</div>
</div>
</div>
<div id="rap-auto-mode-wrap" class="aap-field" style="<?php echo $auto_enabled ? 'display:none;' : ''; ?>">
<span class="aap-label"><?php echo esc_html(Lang::t('auto_first_run')); ?></span>
<div class="aap-field-input">
<div class="aap-inline" style="margin-bottom:8px;">
<label class="aap-radio">
<input type="radio" name="aap_auto_mode" value="now" id="rap-mode-now" checked>
<?php echo esc_html(Lang::t('start_now')); ?>
</label>
<label class="aap-radio">
<input type="radio" name="aap_auto_mode" value="schedule" id="rap-mode-schedule">
<?php echo esc_html(Lang::t('set_schedule')); ?>
</label>
</div>
<div id="rap-schedule-wrap" style="display:none;">
<input type="datetime-local" id="rap-scheduled-at">
<?php $this->page->help(Lang::t('schedule_desc')); ?>
</div>
</div>
</div>
<div class="aap-field">
<label for="rap-auto-interval"><?php echo esc_html(Lang::t('field_auto_period')); ?></label>
<div class="aap-field-input">
<div class="aap-inline">
<input type="number" id="rap-auto-interval" min="1" max="999"
value="<?php echo esc_attr(Options::get('aap_auto_interval_value', 24)); ?>">
<select id="rap-auto-unit">
<?php foreach (Lang::ta('auto_period_units') as $value => $label) : ?>
<option value="<?php echo esc_attr($value); ?>" <?php selected(Options::get('aap_auto_interval_unit', 'hours'), $value); ?>>
<?php echo esc_html($label); ?>
</option>
<?php endforeach; ?>
</select>
<span class="aap-help" style="margin:0;"><?php echo esc_html(Lang::t('auto_period_hint')); ?></span>
</div>
</div>
</div>
<div class="aap-inline" style="margin-top:6px;">
<button type="button" id="rap-toggle-auto" class="aap-btn <?php echo $auto_enabled ? 'aap-btn-outline' : 'aap-btn-primary'; ?>">
<?php echo esc_html($auto_enabled ? Lang::t('stop_auto_btn') : Lang::t('start_auto_btn')); ?>
</button>
<span id="rap-auto-status" class="aap-feedback" role="status" aria-live="polite"></span>
</div>
</div>
</div>
</div>
</section>
<?php
}
}
@@ -0,0 +1,419 @@
<?php
namespace AAP\Modules\Admin\Panels;
use AAP\Core\Options;
use AAP\Modules\Admin\SettingsPage;
use AAP\Modules\Ai\ProviderFactory;
use AAP\Modules\Feeds\GnewsFetcher;
use AAP\Modules\I18n\Lang;
use AAP\Support\Icons;
use AAP\Support\Languages;
defined('ABSPATH') || exit;
/**
* Configurações tab.
*/
final class SettingsPanel {
private SettingsPage $page;
public function __construct(SettingsPage $page) {
$this->page = $page;
}
public function render(): void {
$post_status = (string) Options::get('aap_post_status', 'draft');
$provider = ProviderFactory::current_id();
$gnews_key = (string) Options::get('aap_gnews_api_key', '');
?>
<section class="aap-panel" id="aap-panel-configuracoes" aria-labelledby="aap-h-configuracoes">
<div class="aap-sticky-head">
<header class="aap-settings-head">
<span class="aap-ico aap-ico--blue aap-ico--lg"><?php echo Icons::svg('settings'); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
<div class="aap-panel-head-text">
<h2 class="aap-title" id="aap-h-configuracoes"><?php echo esc_html(Lang::t('tab_configuracoes')); ?></h2>
<p class="aap-subtitle"><?php echo esc_html(Lang::t('config_subtitle')); ?></p>
</div>
</header>
<nav class="aap-cat-links" aria-label="<?php echo esc_attr(Lang::t('config_categories')); ?>">
<a href="#aap-card-geral" data-cat="aap-card-geral"><?php echo esc_html(Lang::t('section_general')); ?></a>
<a href="#aap-card-prompts" data-cat="aap-card-prompts"><?php echo esc_html(Lang::t('section_prompts')); ?></a>
<a href="#aap-card-feeds" data-cat="aap-card-feeds"><?php echo esc_html(Lang::t('section_feeds')); ?></a>
<a href="#aap-card-gnews" data-cat="aap-card-gnews"><?php echo esc_html(Lang::t('section_gnews')); ?></a>
<a href="#aap-card-ai" data-cat="aap-card-ai"><?php echo esc_html(Lang::t('section_ai')); ?></a>
<a href="#aap-card-other" data-cat="aap-card-other"><?php echo esc_html(Lang::t('section_other')); ?></a>
</nav>
</div>
<div class="aap-panel-body">
<form method="post" action="options.php" id="aap-settings-form">
<?php settings_fields('aap_settings'); ?>
<input type="hidden" name="aap_auto_interval_value" value="<?php echo esc_attr(Options::get('aap_auto_interval_value', 24)); ?>">
<input type="hidden" name="aap_auto_interval_unit" value="<?php echo esc_attr(Options::get('aap_auto_interval_unit', 'hours')); ?>">
<div class="aap-cards">
<?php /* ── Geral ── */ ?>
<div class="aap-card" id="aap-card-geral">
<?php $this->page->card_head('settings', 'aap-ico--blue', Lang::t('section_general'), Lang::t('section_general_desc')); ?>
<div class="aap-card-body">
<div class="aap-field">
<span class="aap-label"><?php echo esc_html(Lang::t('field_pub_mode')); ?></span>
<div class="aap-field-input">
<div class="aap-inline">
<label class="aap-radio">
<input type="radio" name="aap_post_status" value="draft" <?php checked($post_status, 'draft'); ?>>
<?php echo esc_html(Lang::t('pub_mode_draft_label')); ?>
</label>
<label class="aap-radio">
<input type="radio" name="aap_post_status" value="publish" <?php checked($post_status, 'publish'); ?>>
<?php echo esc_html(Lang::t('pub_mode_pub_label')); ?>
</label>
</div>
<?php $this->page->help(Lang::t('pub_mode_desc')); ?>
</div>
</div>
<div class="aap-field">
<label for="aap_post_language"><?php echo esc_html(Lang::t('field_language')); ?></label>
<div class="aap-field-input">
<select id="aap_post_language" name="aap_post_language">
<?php foreach (Languages::post_options() as $code => $label) : ?>
<option value="<?php echo esc_attr($code); ?>" <?php selected(Options::get('aap_post_language'), $code); ?>>
<?php echo esc_html($label); ?>
</option>
<?php endforeach; ?>
</select>
<?php $this->page->help(Lang::t('language_desc')); ?>
</div>
</div>
<div class="aap-field">
<label for="aap_app_language"><?php echo esc_html(Lang::t('field_app_language')); ?></label>
<div class="aap-field-input">
<select id="aap_app_language" name="aap_app_language">
<?php foreach (Languages::APP as $code => $label) : ?>
<option value="<?php echo esc_attr($code); ?>" <?php selected(Options::get('aap_app_language'), $code); ?>>
<?php echo esc_html($label); ?>
</option>
<?php endforeach; ?>
</select>
<?php $this->page->help(Lang::t('app_language_desc')); ?>
</div>
</div>
<div class="aap-field">
<span class="aap-label"><?php echo esc_html(Lang::t('field_posts_per_run')); ?></span>
<div class="aap-field-input">
<div class="aap-inline">
<label class="aap-inline-label" for="aap_min_posts"><?php echo esc_html(Lang::t('min_posts')); ?></label>
<input type="number" id="aap_min_posts" name="aap_min_posts" min="1" value="<?php echo esc_attr(Options::get('aap_min_posts', 1)); ?>">
<label class="aap-inline-label" for="aap_max_posts"><?php echo esc_html(Lang::t('max_posts')); ?></label>
<input type="number" id="aap_max_posts" name="aap_max_posts" min="1" value="<?php echo esc_attr(Options::get('aap_max_posts', 3)); ?>">
</div>
</div>
</div>
</div>
</div>
<?php /* ── Prompts e Filtro ── */ ?>
<div class="aap-card" id="aap-card-prompts">
<?php $this->page->card_head('shield', 'aap-ico--purple', Lang::t('section_prompts'), Lang::t('section_prompts_desc')); ?>
<div class="aap-card-body">
<div class="aap-field aap-field-full">
<label for="aap_relevance_description"><?php echo esc_html(Lang::t('field_editorial')); ?></label>
<div class="aap-field-input">
<textarea id="aap_relevance_description" name="aap_relevance_description" rows="4"
placeholder="<?php echo esc_attr(Lang::t('editorial_ph')); ?>"><?php echo $this->page->textarea_value('aap_relevance_description'); ?></textarea>
<?php $this->page->help(Lang::t('editorial_desc')); ?>
</div>
</div>
<div class="aap-field aap-field-full">
<label for="aap_negative_profile"><?php echo esc_html(Lang::t('field_negative')); ?></label>
<div class="aap-field-input">
<textarea id="aap_negative_profile" name="aap_negative_profile" rows="4"
placeholder="<?php echo esc_attr(Lang::t('negative_ph')); ?>"><?php echo $this->page->textarea_value('aap_negative_profile'); ?></textarea>
<?php $this->page->help(Lang::t('negative_desc'), 'strong'); ?>
</div>
</div>
<div class="aap-divider"><?php echo esc_html(Lang::t('rewrite_label')); ?></div>
<div class="aap-field aap-field-full">
<label for="aap_rewrite_instructions"><?php echo esc_html(Lang::t('field_rewrite')); ?></label>
<div class="aap-field-input">
<textarea id="aap_rewrite_instructions" name="aap_rewrite_instructions" rows="4"
placeholder="<?php echo esc_attr(Lang::t('rewrite_ph')); ?>"><?php echo $this->page->textarea_value('aap_rewrite_instructions'); ?></textarea>
<?php $this->page->help(Lang::t('rewrite_desc')); ?>
</div>
</div>
<div class="aap-divider"><?php echo esc_html(Lang::t('image_label')); ?></div>
<div class="aap-field aap-field-full">
<label for="aap_image_instructions"><?php echo esc_html(Lang::t('field_image_instructions')); ?></label>
<div class="aap-field-input">
<textarea id="aap_image_instructions" name="aap_image_instructions" rows="4"
placeholder="<?php echo esc_attr(Lang::t('image_instructions_ph')); ?>"><?php echo $this->page->textarea_value('aap_image_instructions'); ?></textarea>
<?php $this->page->help(Lang::t('image_instructions_desc')); ?>
</div>
</div>
<div class="aap-field aap-field-full">
<label for="aap_image_caption"><?php echo esc_html(Lang::t('field_image_caption')); ?></label>
<div class="aap-field-input">
<textarea id="aap_image_caption" name="aap_image_caption" rows="3"
placeholder="<?php echo esc_attr(Lang::t('image_caption_ph')); ?>"><?php echo $this->page->textarea_value('aap_image_caption'); ?></textarea>
<?php $this->page->help(Lang::t('image_caption_desc')); ?>
</div>
</div>
</div>
</div>
<?php /* ── Feeds RSS ── */ ?>
<div class="aap-card" id="aap-card-feeds">
<?php $this->page->card_head('rss', 'aap-ico--violet', Lang::t('section_feeds'), Lang::t('section_feeds_desc')); ?>
<div class="aap-card-body">
<div class="aap-field aap-field-full">
<label for="aap_feeds"><?php echo esc_html(Lang::t('field_feeds')); ?></label>
<div class="aap-field-input">
<textarea id="aap_feeds" name="aap_feeds" rows="5" placeholder="https://exemplo.com/rss.xml"><?php echo esc_textarea((string) Options::get('aap_feeds', '')); ?></textarea>
<?php $this->page->help(Lang::t('feeds_desc')); ?>
</div>
</div>
<div class="aap-field">
<label for="aap_feed_max_items"><?php echo esc_html(Lang::t('field_feed_max')); ?></label>
<div class="aap-field-input">
<input type="number" id="aap_feed_max_items" name="aap_feed_max_items" min="1" max="100" value="<?php echo esc_attr(Options::get('aap_feed_max_items', 10)); ?>">
<?php $this->page->help(Lang::t('feed_max_desc')); ?>
</div>
</div>
</div>
</div>
<?php /* ── GNews.io ── */ ?>
<div class="aap-card" id="aap-card-gnews">
<?php $this->page->card_head('gnews', 'aap-ico--green', Lang::t('section_gnews'), Lang::t('section_gnews_desc')); ?>
<div class="aap-card-body">
<div class="aap-field">
<label for="aap_gnews_api_key"><?php echo esc_html(Lang::t('field_gnews_key')); ?></label>
<div class="aap-field-input">
<?php $has_gnews = $gnews_key !== ''; ?>
<input type="password" id="aap_gnews_api_key" name="aap_gnews_api_key" value=""
placeholder="<?php echo $has_gnews ? esc_attr('••••••••••••') : ''; ?>"
autocomplete="new-password" spellcheck="false">
<?php if ($has_gnews) : ?>
<?php $this->page->help(Lang::t('key_saved_hint')); ?>
<?php endif; ?>
<?php $this->page->help(Lang::t('gnews_key_desc')); ?>
</div>
</div>
<div class="aap-field">
<span class="aap-label"><?php echo esc_html(Lang::t('field_gnews_cats')); ?></span>
<div class="aap-field-input">
<div class="aap-check-grid">
<?php
$selected = (array) Options::get('aap_gnews_categories', []);
$labels = Lang::ta('gnews_categories') ?: GnewsFetcher::CATEGORIES;
foreach ($labels as $value => $label) :
?>
<label class="aap-check">
<input type="checkbox" name="aap_gnews_categories[]" value="<?php echo esc_attr($value); ?>" <?php checked(in_array($value, $selected, true)); ?>>
<?php echo esc_html($label); ?>
</label>
<?php endforeach; ?>
</div>
<?php $this->page->help(Lang::t('gnews_cats_desc')); ?>
</div>
</div>
<div class="aap-field">
<label for="aap_gnews_max"><?php echo esc_html(Lang::t('field_gnews_max')); ?></label>
<div class="aap-field-input">
<input type="number" id="aap_gnews_max" name="aap_gnews_max" min="1" max="100" value="<?php echo esc_attr(Options::get('aap_gnews_max', 10)); ?>">
<?php $this->page->help(Lang::t('gnews_max_desc')); ?>
</div>
</div>
</div>
</div>
<?php /* ── Inteligência Artificial ── */ ?>
<div class="aap-card" id="aap-card-ai">
<?php $this->page->card_head('ai', 'aap-ico--orange', Lang::t('section_ai'), Lang::t('section_ai_desc')); ?>
<div class="aap-card-body">
<div class="aap-field">
<label for="aap_ai_provider"><?php echo esc_html(Lang::t('field_provider')); ?></label>
<div class="aap-field-input">
<select id="aap_ai_provider" name="aap_ai_provider">
<?php foreach (ProviderFactory::LABELS as $id => $label) : ?>
<option value="<?php echo esc_attr($id); ?>" <?php selected($provider, $id); ?>><?php echo esc_html($label); ?></option>
<?php endforeach; ?>
</select>
<?php $this->page->help(Lang::t('provider_desc')); ?>
</div>
</div>
<?php $this->provider_block('nvidia', Lang::t('field_nvidia_key'), 'aap_nvidia_api_key', Lang::t('nvidia_key_desc')); ?>
<?php $this->provider_block('openai', Lang::t('field_openai_key'), 'aap_openai_api_key', Lang::t('openai_key_desc')); ?>
<?php $this->provider_block('anthropic', Lang::t('field_anthropic_key'), 'aap_anthropic_api_key', Lang::t('anthropic_key_desc')); ?>
<?php $this->provider_block('openrouter', Lang::t('field_openrouter_key'), 'aap_openrouter_api_key', Lang::t('openrouter_key_desc')); ?>
<div class="aap-provider-block" data-provider="custom" <?php echo $provider === 'custom' ? '' : 'hidden'; ?>>
<div class="aap-field">
<label for="aap_custom_base_url"><?php echo esc_html(Lang::t('field_custom_url')); ?></label>
<div class="aap-field-input">
<input type="url" id="aap_custom_base_url" name="aap_custom_base_url"
value="<?php echo esc_attr(Options::get('aap_custom_base_url', '')); ?>"
placeholder="https://api.exemplo.com/v1">
<?php $this->page->help(Lang::t('custom_url_desc')); ?>
</div>
</div>
<?php $this->provider_block('custom', Lang::t('field_custom_key'), 'aap_custom_api_key', Lang::t('custom_key_desc'), false); ?>
</div>
<div class="aap-provider-block" data-provider="vertex" <?php echo $provider === 'vertex' ? '' : 'hidden'; ?>>
<div class="aap-field">
<label for="aap_vertex_project"><?php echo esc_html(Lang::t('field_vertex_project')); ?></label>
<div class="aap-field-input">
<input type="text" id="aap_vertex_project" name="aap_vertex_project" value="<?php echo esc_attr(Options::get('aap_vertex_project', '')); ?>">
</div>
</div>
<div class="aap-field">
<label for="aap_vertex_location"><?php echo esc_html(Lang::t('field_vertex_location')); ?></label>
<div class="aap-field-input">
<input type="text" id="aap_vertex_location" name="aap_vertex_location" value="<?php echo esc_attr(Options::get('aap_vertex_location', 'us-central1')); ?>">
</div>
</div>
<div class="aap-field aap-field-full">
<label for="aap_vertex_service_account"><?php echo esc_html(Lang::t('field_vertex_sa')); ?></label>
<div class="aap-field-input">
<textarea id="aap_vertex_service_account" name="aap_vertex_service_account" rows="5" spellcheck="false"
placeholder='{"type":"service_account", ...}'><?php echo esc_textarea((string) Options::get('aap_vertex_service_account', '')); ?></textarea>
<?php $this->page->help(Lang::t('vertex_sa_desc')); ?>
</div>
</div>
</div>
<div class="aap-field">
<label for="aap_model"><?php echo esc_html(Lang::t('field_model')); ?></label>
<div class="aap-field-input">
<input type="text" id="aap_model" name="aap_model"
value="<?php echo esc_attr(Options::get('aap_model', '')); ?>"
data-default-model="<?php echo esc_attr(ProviderFactory::DEFAULT_MODELS[$provider] ?? ''); ?>"
placeholder="<?php echo esc_attr(ProviderFactory::DEFAULT_MODELS[$provider] ?? ''); ?>">
<?php $this->page->help(Lang::t('model_desc')); ?>
</div>
</div>
<div class="aap-field">
<span class="aap-label"><?php echo esc_html(Lang::t('field_ai_limits')); ?></span>
<div class="aap-field-input">
<div class="aap-inline">
<label class="aap-inline-label" for="aap_ai_timeout"><?php echo esc_html(Lang::t('timeout_label')); ?></label>
<input type="number" id="aap_ai_timeout" name="aap_ai_timeout" min="10" class="aap-w-70" value="<?php echo esc_attr(Options::get('aap_ai_timeout', 120)); ?>">
<label class="aap-inline-label" for="aap_ai_max_retries"><?php echo esc_html(Lang::t('retries_label')); ?></label>
<input type="number" id="aap_ai_max_retries" name="aap_ai_max_retries" min="1" max="10" class="aap-w-70" value="<?php echo esc_attr(Options::get('aap_ai_max_retries', 3)); ?>">
</div>
<?php $this->page->help(Lang::t('ai_limits_desc')); ?>
</div>
</div>
<div class="aap-field">
<span class="aap-label"><?php echo esc_html(Lang::t('ai_test_label')); ?></span>
<div class="aap-field-input">
<button type="button" class="aap-btn aap-btn-outline" id="aap-test-ai-btn"
data-nonce="<?php echo esc_attr(wp_create_nonce('aap_test_ai')); ?>">
<?php echo Icons::svg('bolt'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span><?php echo esc_html(Lang::t('ai_test_btn')); ?></span>
</button>
<span class="aap-feedback" id="aap-test-ai-feedback" role="status" aria-live="polite"></span>
<?php $this->page->help(Lang::t('ai_test_desc')); ?>
</div>
</div>
</div>
</div>
<?php /* ── Outras Configurações ── */ ?>
<div class="aap-card" id="aap-card-other">
<?php $this->page->card_head('info', 'aap-ico--teal', Lang::t('section_other'), Lang::t('section_other_desc')); ?>
<div class="aap-card-body">
<div class="aap-field">
<span class="aap-label"><?php echo esc_html(Lang::t('other_version')); ?></span>
<div class="aap-field-input">
<strong>Automatic AI Posts v<?php echo esc_html(AAP_VERSION); ?></strong>
</div>
</div>
<div class="aap-field">
<span class="aap-label"><?php echo esc_html(Lang::t('other_support')); ?></span>
<div class="aap-field-input">
<a class="aap-btn aap-btn-outline" href="<?php echo esc_url($this->page->tab_url('ajuda')); ?>">
<?php echo Icons::svg('help'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span><?php echo esc_html(Lang::t('tab_ajuda')); ?></span>
</a>
</div>
</div>
</div>
</div>
</div>
</form>
<div class="aap-toast" id="aap-save-toast" hidden>
<?php echo Icons::svg('check'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span id="aap-save-feedback" role="status" aria-live="polite"></span>
</div>
<div class="aap-save-float" id="aap-save-float" hidden>
<span class="aap-save-float-note"><?php echo esc_html(Lang::t('unsaved_changes')); ?></span>
<button type="button" class="aap-btn aap-btn-outline" id="aap-discard-all">
<?php echo Icons::svg('refresh'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span><?php echo esc_html(Lang::t('discard_changes')); ?></span>
</button>
<button type="submit" form="aap-settings-form" class="aap-btn aap-btn-primary" id="aap-save-float-btn">
<?php echo Icons::svg('check'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<span><?php echo esc_html(Lang::t('save_all')); ?></span>
</button>
</div>
</div>
</section>
<?php
}
/** Masked API key field. Never prints the stored key. */
private function provider_block(string $provider, string $label, string $option, string $help, bool $wrap = true): void {
$has_key = (string) Options::get($option, '') !== '';
$active = ProviderFactory::current_id() === $provider;
?>
<div class="aap-provider-block" data-provider="<?php echo esc_attr($provider); ?>" <?php echo $active ? '' : 'hidden'; ?>>
<div class="aap-field">
<label for="<?php echo esc_attr($option); ?>"><?php echo esc_html($label); ?></label>
<div class="aap-field-input">
<input type="password" id="<?php echo esc_attr($option); ?>" name="<?php echo esc_attr($option); ?>" value=""
placeholder="<?php echo $has_key ? esc_attr('••••••••••••') : ''; ?>"
autocomplete="new-password" spellcheck="false">
<?php if ($has_key) : ?>
<?php $this->page->help(Lang::t('key_saved_hint')); ?>
<?php endif; ?>
<?php $this->page->help($help); ?>
</div>
</div>
</div>
<?php
}
}
@@ -0,0 +1,172 @@
<?php
namespace AAP\Modules\Admin;
use AAP\Core\Options;
use AAP\Modules\Admin\Panels\HelpPanel;
use AAP\Modules\Admin\Panels\LogPanel;
use AAP\Modules\Admin\Panels\RunPanel;
use AAP\Modules\Admin\Panels\SettingsPanel;
use AAP\Modules\I18n\Lang;
use AAP\Modules\Logs\Logger;
use AAP\Support\Icons;
defined('ABSPATH') || exit;
/**
* Admin page shell: sidebar, tab routing and shared markup helpers.
*/
final class SettingsPage {
public const TABS = ['execucoes', 'configuracoes', 'log', 'ajuda'];
public function render(): void {
if (!current_user_can('manage_options')) {
return;
}
$tab = $this->current_tab();
?>
<div class="wrap">
<h1 class="screen-reader-text">Automatic AI Posts</h1>
<div id="aap-app" data-tab="<?php echo esc_attr($tab); ?>">
<div class="aap-shell">
<aside class="aap-sidebar">
<div class="aap-sidebar-inner">
<div class="aap-brand">
<span class="aap-brand-icon"><?php echo Icons::logo(); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
<span class="aap-brand-title">Automatic<br>AI Posts</span>
</div>
<nav class="aap-nav" aria-label="<?php echo esc_attr(Lang::t('nav_label')); ?>">
<?php
$this->nav_item('execucoes', 'play', Lang::t('tab_execucoes'), $tab);
$this->nav_item('configuracoes', 'settings', Lang::t('tab_configuracoes'), $tab);
$this->nav_item('log', 'list', Lang::t('tab_log'), $tab);
$this->nav_item('ajuda', 'help', Lang::t('tab_ajuda'), $tab);
?>
</nav>
</div>
</aside>
<main class="aap-main">
<?php
switch ($tab) {
case 'configuracoes':
(new SettingsPanel($this))->render();
break;
case 'log':
(new LogPanel($this))->render();
break;
case 'ajuda':
(new HelpPanel($this))->render();
break;
default:
(new RunPanel($this))->render();
}
?>
</main>
</div>
</div>
</div>
<?php
}
public function current_tab(): string {
$tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'execucoes';
return in_array($tab, self::TABS, true) ? $tab : 'execucoes';
}
public function tab_url(string $tab, array $extra = []): string {
return add_query_arg(
array_merge(['page' => 'automatic-ai-posts', 'tab' => $tab], $extra),
admin_url('admin.php')
);
}
/** Keeps the active tab after the settings form is saved. */
public function keep_tab_on_redirect($location) {
$location = (string) $location;
if (str_contains($location, 'page=automatic-ai-posts')) {
if (!str_contains($location, 'tab=')) {
$location = add_query_arg('tab', 'configuracoes', $location);
}
if (str_contains($location, 'settings-updated=true')) {
$location = add_query_arg('aap-saved', '1', $location);
Logger::info('Settings saved by user ' . get_current_user_id() . '.', 'admin');
}
}
return $location;
}
public function nav_item(string $tab, string $icon, string $label, string $current): void {
$active = $tab === $current;
printf(
'<a class="aap-nav-item%s" href="%s"%s><span class="aap-nav-icon">%s</span><span>%s</span></a>',
$active ? ' aap-active' : '',
esc_url($this->tab_url($tab)),
$active ? ' aria-current="page"' : '',
Icons::svg($icon), // phpcs:ignore WordPress.Security.EscapeOutput
esc_html($label)
);
}
public function card_head(string $icon, string $variant, string $title, string $desc): void {
?>
<div class="aap-card-head">
<span class="aap-ico <?php echo esc_attr($variant); ?>"><?php echo Icons::svg($icon); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
<span class="aap-card-heading">
<span class="aap-card-title"><?php echo esc_html($title); ?></span>
<span class="aap-card-desc"><?php echo esc_html($desc); ?></span>
</span>
</div>
<?php
}
public function help(string $text, string $allowed = 'a,strong,code,br'): void {
if ($text === '') {
return;
}
$map = [
'a' => ['href' => [], 'target' => [], 'rel' => []],
'strong' => [],
'code' => [],
'br' => [],
];
$tags = [];
foreach (explode(',', $allowed) as $tag) {
$tag = trim($tag);
if (isset($map[$tag])) {
$tags[$tag] = $map[$tag];
}
}
echo '<p class="aap-help">' . wp_kses($text, $tags) . '</p>';
}
public function panel_head(string $icon, string $title_id, string $title, string $subtitle, string $extra = ''): void {
?>
<div class="aap-sticky-head">
<header class="aap-panel-head">
<span class="aap-ico aap-ico--blue aap-ico--lg"><?php echo Icons::svg($icon); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
<div class="aap-panel-head-text">
<h2 class="aap-title" id="<?php echo esc_attr($title_id); ?>"><?php echo esc_html($title); ?></h2>
<p class="aap-subtitle"><?php echo esc_html($subtitle); ?></p>
</div>
<?php echo $extra; // phpcs:ignore WordPress.Security.EscapeOutput ?>
</header>
</div>
<?php
}
public function textarea_value(string $key): string {
return esc_textarea((string) Options::get($key, ''));
}
}
@@ -0,0 +1,21 @@
<?php
namespace AAP\Modules\Ai;
use AAP\Core\Module;
defined('ABSPATH') || exit;
/**
* AI providers (NVIDIA Build, OpenAI, Anthropic, OpenRouter, Vertex AI, custom)
* and the prompt library.
*/
final class AiModule extends Module {
public function id(): string {
return 'ai';
}
public function dependencies(): array {
return ['logs', 'i18n'];
}
}
@@ -0,0 +1,321 @@
<?php
namespace AAP\Modules\Ai;
use AAP\Core\Options;
use AAP\Modules\Logs\Logger;
use AAP\Support\Languages;
defined('ABSPATH') || exit;
/**
* High level AI operations used by the pipeline.
* Handles retries, parsing and logging so callers stay small.
*/
final class AiService {
private ?Provider $provider;
private int $retries;
public function __construct(?Provider $provider = null) {
$this->provider = $provider ?? ProviderFactory::make();
$this->retries = max(1, Options::int('aap_ai_max_retries'));
}
public function is_ready(): bool {
return $this->provider !== null && $this->provider->is_configured();
}
public function provider_label(): string {
return $this->provider ? $this->provider->label() : 'none';
}
public function model(): string {
return $this->provider ? $this->provider->default_model() : '';
}
/**
* Send one tiny prompt to confirm the configured provider actually answers.
*
* Uses a single attempt on purpose: a "test" button should report the real
* latency and error instead of hiding a broken key behind retries.
*
* @return array{ok:bool,provider:string,model:string,reply:string,error:string,ms:int}
*/
public function test(): array {
$result = [
'ok' => false,
'provider' => $this->provider ? $this->provider->label() : 'none',
'model' => $this->model(),
'reply' => '',
'error' => '',
'ms' => 0,
];
if (!$this->provider || !$this->provider->is_configured()) {
$result['error'] = __('The provider is not configured yet.', 'automatic-ai-posts');
return $result;
}
$started = microtime(true);
$reply = $this->provider->complete('Reply with the single word: OK', 16, 0.0);
$elapsed = (int) round((microtime(true) - $started) * 1000);
$result['ms'] = $elapsed;
if ($reply === null || trim($reply) === '') {
$result['error'] = __('The provider did not answer. Check the API key, the model name and the recent log entries.', 'automatic-ai-posts');
Logger::error('AI test failed via ' . $result['provider'] . " after {$elapsed}ms.", 'ai');
return $result;
}
$result['ok'] = true;
$result['reply'] = trim(wp_strip_all_tags($reply));
Logger::info('AI test succeeded via ' . $result['provider'] . " in {$elapsed}ms.", 'ai');
return $result;
}
/**
* @param array<int, array{title:string,content:string,link:string,source:string}> $articles
* @param string[] $recent_titles
* @return array<int, array>
*/
public function select_topics(array $articles, int $min, int $max, array $recent_titles = []): array {
if (empty($articles)) {
return [];
}
$prompt = PromptLibrary::select_topics($articles, $min, $max, $recent_titles);
$raw = $this->ask($prompt, 'topic selection', 2048, 0.2);
if ($raw === null) {
return [];
}
$indexes = $this->parse_number_list($raw, count($articles));
if (empty($indexes)) {
Logger::warn('AI topic selection returned no valid index: "' . substr($raw, 0, 120) . '"', 'ai');
return [];
}
$selected = [];
foreach ($indexes as $index) {
$real = (int) $index - 1;
if (isset($articles[$real])) {
$selected[] = $articles[$real];
}
}
// Never trust the model for the upper bound.
return array_slice($selected, 0, $max);
}
/**
* @param array{title:string,content:string,link:string,source:string} $article
* @return array{title:string,content:string}|null
*/
public function rewrite(array $article, string $language): ?array {
$prompt = PromptLibrary::rewrite($article, $language);
$raw = $this->ask($prompt, 'article rewrite', 8192, 0.8);
if ($raw === null) {
return null;
}
$sections = $this->parse_sections($raw, ['TITLE', 'CONTENT']);
if (!empty($sections['TITLE']) && !empty($sections['CONTENT'])) {
return ['title' => $sections['TITLE'], 'content' => $sections['CONTENT']];
}
$json = $this->parse_json($raw);
if ($json && !empty($json['title']) && !empty($json['content'])) {
Logger::warn('Rewrite ignored the delimiter format; recovered from JSON.', 'ai');
return ['title' => (string) $json['title'], 'content' => (string) $json['content']];
}
Logger::warn('Rewrite response could not be parsed: "' . substr($raw, 0, 200) . '"', 'ai');
return null;
}
/**
* @param array<int,string> $categories id => name
*/
public function choose_category(string $title, string $content, array $categories, string $language): int {
if (empty($categories)) {
return 0;
}
$prompt = PromptLibrary::choose_category($title, $content, $categories, $language);
$raw = $this->ask($prompt, 'category choice', 2048, 0.2);
if ($raw === null) {
return 0;
}
// Prefer a line that contains only the id (reasoning models add prose).
foreach (array_reverse(preg_split('/\r\n|\r|\n/', $raw)) as $line) {
$line = trim($line);
if (preg_match('/^\d+$/', $line)) {
$id = (int) $line;
return isset($categories[$id]) ? $id : 0;
}
}
// Fallback: the last valid id mentioned anywhere.
preg_match_all('/\b(\d{1,6})\b/', $raw, $m);
foreach (array_reverse($m[1] ?? []) as $candidate) {
$id = (int) $candidate;
if (isset($categories[$id])) {
return $id;
}
}
return 0;
}
/**
* Returns the DuckDuckGo query plus the image metadata in a single call.
*
* @return array{query:string,alt:string,caption:string,description:string}|null
*/
public function image_plan(string $title, string $content, string $language): ?array {
$prompt = PromptLibrary::image_plan($title, $content, $language);
$raw = $this->ask($prompt, 'image plan', 4000, 0.7);
if ($raw === null) {
return null;
}
$sections = $this->parse_sections($raw, ['QUERY', 'ALT', 'CAPTION', 'DESCRIPTION']);
$query = $this->clean_value($sections['QUERY'] ?? '');
if ($query === '') {
Logger::warn('Image plan without a usable search query: "' . substr($raw, 0, 200) . '"', 'images');
return null;
}
return [
'query' => $query,
'alt' => $this->clean_value($sections['ALT'] ?? ''),
'caption' => $this->clean_value($sections['CAPTION'] ?? ''),
'description' => $this->clean_value($sections['DESCRIPTION'] ?? ''),
];
}
/** Calls the provider with retries. Returns null when every attempt failed. */
private function ask(string $prompt, string $label, int $max_tokens, float $temperature): ?string {
if (!$this->provider) {
Logger::error('No AI provider configured.', 'ai');
return null;
}
if (!$this->provider->is_configured()) {
Logger::error('AI provider "' . $this->provider->label() . '" is not configured.', 'ai');
return null;
}
for ($attempt = 1; $attempt <= $this->retries; $attempt++) {
Logger::info("AI request: {$label} (attempt {$attempt}/{$this->retries}) via {$this->provider->label()}", 'ai');
$raw = $this->provider->complete($prompt, $max_tokens, $temperature);
if ($raw !== null) {
Logger::info("AI response received for {$label}.", 'ai');
return $raw;
}
Logger::warn("AI attempt {$attempt}/{$this->retries} failed for {$label}.", 'ai');
}
Logger::error("AI request failed after {$this->retries} attempt(s): {$label}", 'ai');
return null;
}
/**
* Rejects placeholder text that reasoning models sometimes echo back
* (e.g. "search query here", "alt text here").
*/
private function clean_value(string $value): string {
$value = trim($value, " \t\n\r\"'");
$lower = mb_strtolower($value);
$blocked = [
'search query here', 'query here', 'search query', 'query',
'alt text here', 'caption here', 'description here',
'example', 'exemplo', 'n/a', 'none', 'null', '...',
];
if ($value === '' || mb_strlen($value) < 3 || in_array($lower, $blocked, true)) {
return '';
}
return $value;
}
/**
* Extracts a list of article indexes from the model answer.
* Reasoning models often repeat the whole list while thinking, so the
* last "numbers only" line wins; otherwise the last numbers are used.
*
* @return int[]
*/
private function parse_number_list(string $raw, int $total): array {
$candidates = [];
foreach (array_reverse(preg_split('/\r\n|\r|\n/', $raw)) as $line) {
$line = trim($line);
if ($line !== '' && preg_match('/^\d+(?:\s*[,\s]\s*\d+)*$/', $line)) {
$candidates = preg_split('/[,\s]+/', $line);
break;
}
}
if (empty($candidates)) {
preg_match_all('/\b(\d{1,3})\b/', $raw, $m);
$candidates = $m[1] ?? [];
}
$out = [];
foreach ($candidates as $candidate) {
$index = (int) $candidate;
if ($index >= 1 && $index <= $total && !in_array($index, $out, true)) {
$out[] = $index;
}
}
return $out;
}
/**
* Parses "===KEY=== value" blocks.
*
* @param string[] $keys
* @return array<string,string>
*/
private function parse_sections(string $raw, array $keys): array {
$out = [];
foreach ($keys as $key) {
// Stops at the next "===" marker (any key) or at the end of the
// answer, so truncated responses still yield usable content.
$pattern = '/===' . preg_quote($key, '/') . '===\s*(.*?)\s*(?====|\z)/s';
if (preg_match($pattern, $raw, $m)) {
$out[$key] = trim($m[1]);
}
}
return $out;
}
/** @return array<string,mixed>|null */
private function parse_json(string $raw): ?array {
if (preg_match('/\{.*\}/s', $raw, $m)) {
$data = json_decode($m[0], true);
return is_array($data) ? $data : null;
}
return null;
}
}
@@ -0,0 +1,164 @@
<?php
namespace AAP\Modules\Ai;
use AAP\Core\Options;
use AAP\Support\Languages;
defined('ABSPATH') || exit;
/**
* Every prompt lives here.
*
* Rules:
* - prompts are written in English (better model adherence);
* - the model is told to answer in the configured post language;
* - the user's own instructions are injected verbatim, so the panel inputs
* are the only place needed to shape the output.
*/
final class PromptLibrary {
public static function select_topics(array $articles, int $min, int $max, array $recent_titles): string {
$language = Languages::prompt_name(Languages::current_post());
$relevance = trim((string) Options::get('aap_relevance_description', ''));
$negative = trim((string) Options::get('aap_negative_profile', ''));
$list = '';
foreach ($articles as $i => $article) {
$list .= ($i + 1) . '. [' . $article['title'] . '] — ' . $article['source'] . "\n";
}
$total = count($articles);
$blocks = [];
if ($relevance !== '') {
$blocks[] = "Editorial profile (prioritize articles that match this):\n{$relevance}";
}
if ($negative !== '') {
$blocks[] = "Never select articles that match any of these exclusion criteria:\n{$negative}";
}
if (!empty($recent_titles)) {
$blocks[] = "Already published on this blog (do not repeat these topics):\n- " . implode("\n- ", $recent_titles);
}
$context = $blocks ? implode("\n\n", $blocks) . "\n\n" : '';
return <<<PROMPT
You are an experienced news editor. Below are {$total} articles collected from several sources.
Select between {$min} and {$max} articles to publish on the blog.
Selection rules (apply them strictly):
1. The exclusion list below is MANDATORY. If an article's main subject matches ANY excluded topic, it must not be selected — no exceptions, even if it is popular or trending.
2. Prefer articles that clearly match the editorial profile.
3. Never select two articles about the same topic.
4. Never select a topic already covered by the recent posts listed below.
5. When in doubt about an article matching an excluded topic, skip it.
{$context}Reply ONLY with the selected article numbers separated by commas, no extra text.
Example: 2,5,8
Available articles:
{$list}
PROMPT;
}
public static function rewrite(array $article, string $language_code): string {
$language = Languages::prompt_name($language_code);
$custom = trim((string) Options::get('aap_rewrite_instructions', ''));
$custom_block = $custom !== ''
? "Additional instructions from the blog owner (follow them):\n{$custom}\n\n"
: '';
return <<<PROMPT
You are a journalist and writer. Rewrite the article below as a completely original piece.
Write the title and the body in {$language}. The output language must be {$language}, regardless of the source language.
{$custom_block}Rules:
- Create an engaging, SEO-friendly title.
- Write the body as simple HTML using <p>, <h2>, <ul> and <strong>.
- Minimum of 400 words.
- Never mention the original source or that the text was rewritten.
Reply EXACTLY in this format, with nothing before or after:
===TITLE===
Title here
===CONTENT===
HTML content here
===END===
Original article:
Title: {$article['title']}
Source: {$article['source']}
Content: {$article['content']}
PROMPT;
}
public static function choose_category(string $title, string $content, array $categories, string $language_code): string {
$language = Languages::prompt_name($language_code);
$list = '';
foreach ($categories as $id => $name) {
$list .= "- ID {$id}: {$name}\n";
}
$excerpt = wp_trim_words(wp_strip_all_tags($content), 60);
return <<<PROMPT
You are a blog editor. Choose the most suitable category for the article below.
Reply ONLY with the numeric category ID, no extra text.
Valid example: 5
Available categories:
{$list}
Article title: {$title}
Excerpt: {$excerpt}
PROMPT;
}
/**
* One call produces both the DuckDuckGo query and the image metadata,
* so the search and the alt/caption stay consistent with each other.
*/
public static function image_plan(string $title, string $content, string $language_code): string {
$language = Languages::prompt_name($language_code);
$extra = trim((string) Options::get('aap_image_instructions', ''));
$caption = trim((string) Options::get('aap_image_caption', ''));
$excerpt = wp_trim_words(wp_strip_all_tags($content), 70);
$blocks = [];
if ($extra !== '') {
$blocks[] = "Image instructions from the blog owner (follow them when choosing the photo and writing the texts):\n{$extra}";
}
if ($caption !== '') {
$blocks[] = "Caption style/template required by the blog owner:\n{$caption}";
}
$context = $blocks ? implode("\n\n", $blocks) . "\n\n" : '';
return <<<PROMPT
You are an image editor for a news blog.
Task 1 — search query: write a short DuckDuckGo image search query (2 to 6 words) that would find a real photo representing this article. Use concrete subjects, names, places or objects. Do not use quotes, operators or file extensions.
Task 2 — metadata: write the alt text, the caption and a description for the cover image.
Write every text in {$language}. The output language must be {$language}.
{$context}Reply EXACTLY in this format, with nothing before or after:
===QUERY===
search query here
===ALT===
alt text here
===CAPTION===
caption here
===DESCRIPTION===
description here
===END===
Article title: {$title}
Article excerpt: {$excerpt}
PROMPT;
}
}
@@ -0,0 +1,28 @@
<?php
namespace AAP\Modules\Ai;
defined('ABSPATH') || exit;
/**
* Contract every AI backend must implement.
*/
interface Provider {
/** Machine id (used in options and in the UI). */
public function id(): string;
/** Human readable name. */
public function label(): string;
/** Model used when the user leaves the model field empty. */
public function default_model(): string;
/** Whether the provider has everything it needs to be called. */
public function is_configured(): bool;
/**
* Sends a single prompt and returns the raw text answer.
* Returns null when the call failed (the caller decides about retries).
*/
public function complete(string $prompt, int $max_tokens = 4096, float $temperature = 0.7): ?string;
}
@@ -0,0 +1,62 @@
<?php
namespace AAP\Modules\Ai;
use AAP\Core\Options;
use AAP\Modules\Ai\Providers\AnthropicProvider;
use AAP\Modules\Ai\Providers\CustomProvider;
use AAP\Modules\Ai\Providers\NvidiaProvider;
use AAP\Modules\Ai\Providers\OpenAiProvider;
use AAP\Modules\Ai\Providers\OpenRouterProvider;
use AAP\Modules\Ai\Providers\VertexProvider;
defined('ABSPATH') || exit;
/**
* Builds the provider selected in the settings.
*/
final class ProviderFactory {
public const LABELS = [
'nvidia' => 'NVIDIA Build',
'openai' => 'OpenAI',
'anthropic' => 'Anthropic',
'openrouter' => 'OpenRouter',
'vertex' => 'Google Vertex AI',
'custom' => 'Custom (OpenAI compatible)',
];
public const DEFAULT_MODELS = [
'nvidia' => 'nvidia/nemotron-3-ultra-550b-a55b',
'openai' => 'gpt-4o-mini',
'anthropic' => 'claude-3-5-sonnet-latest',
'openrouter' => 'openai/gpt-4o-mini',
'vertex' => 'gemini-2.0-flash',
'custom' => 'gpt-4o-mini',
];
public static function current_id(): string {
$id = (string) Options::get('aap_ai_provider', 'nvidia');
return isset(self::LABELS[$id]) ? $id : 'nvidia';
}
public static function make(?string $provider_id = null): ?Provider {
$id = $provider_id ?? self::current_id();
$timeout = max(10, Options::int('aap_ai_timeout'));
$model = trim((string) Options::get('aap_model', ''));
return match ($id) {
'openai' => new OpenAiProvider((string) Options::get('aap_openai_api_key', ''), $model, $timeout),
'anthropic' => new AnthropicProvider((string) Options::get('aap_anthropic_api_key', ''), $model, $timeout),
'openrouter' => new OpenRouterProvider((string) Options::get('aap_openrouter_api_key', ''), $model, $timeout),
'vertex' => new VertexProvider(
(string) Options::get('aap_vertex_project', ''),
(string) Options::get('aap_vertex_location', 'us-central1'),
(string) Options::get('aap_vertex_service_account', ''),
$model,
$timeout
),
'custom' => new CustomProvider((string) Options::get('aap_custom_api_key', ''), $model, $timeout),
default => new NvidiaProvider((string) Options::get('aap_nvidia_api_key', ''), $model, $timeout),
};
}
}
@@ -0,0 +1,66 @@
<?php
namespace AAP\Modules\Ai\Providers;
use AAP\Modules\Ai\Provider;
use AAP\Modules\Logs\Logger;
defined('ABSPATH') || exit;
final class AnthropicProvider implements Provider {
private string $api_key;
private string $model;
private int $timeout;
public function __construct(string $api_key, string $model, int $timeout) {
$this->api_key = trim($api_key);
$this->model = trim($model) !== '' ? trim($model) : $this->default_model();
$this->timeout = max(10, $timeout);
}
public function id(): string { return 'anthropic'; }
public function label(): string { return 'Anthropic'; }
public function default_model(): string { return 'claude-3-5-sonnet-latest'; }
public function is_configured(): bool { return $this->api_key !== ''; }
public function complete(string $prompt, int $max_tokens = 4096, float $temperature = 0.7): ?string {
if (!$this->is_configured()) {
Logger::error('Anthropic is not configured (missing API key).', 'ai');
return null;
}
$response = wp_remote_post('https://api.anthropic.com/v1/messages', [
'timeout' => $this->timeout,
'headers' => [
'x-api-key' => $this->api_key,
'anthropic-version' => '2023-06-01',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => wp_json_encode([
'model' => $this->model,
'max_tokens' => $max_tokens,
'temperature' => $temperature,
'messages' => [['role' => 'user', 'content' => $prompt]],
]),
]);
if (is_wp_error($response)) {
Logger::warn('Anthropic request failed: ' . $response->get_error_message(), 'ai');
return null;
}
$code = (int) wp_remote_retrieve_response_code($response);
$body = (string) wp_remote_retrieve_body($response);
if ($code < 200 || $code >= 300) {
Logger::warn('Anthropic HTTP ' . $code . ': ' . substr($body, 0, 300), 'ai');
return null;
}
$data = json_decode($body, true);
$content = $data['content'][0]['text'] ?? null;
return is_string($content) && trim($content) !== '' ? trim($content) : null;
}
}
@@ -0,0 +1,25 @@
<?php
namespace AAP\Modules\Ai\Providers;
use AAP\Core\Options;
defined('ABSPATH') || exit;
/**
* Any endpoint that implements the OpenAI /chat/completions contract.
*/
final class CustomProvider extends OpenAiCompatible {
public function id(): string { return 'custom'; }
public function label(): string { return 'Custom (OpenAI compatible)'; }
public function default_model(): string { return 'gpt-4o-mini'; }
protected function base_url(): string {
$base = trim((string) Options::get('aap_custom_base_url', ''));
return $base !== '' ? $base : 'https://api.openai.com/v1';
}
public function is_configured(): bool {
return parent::is_configured() && trim((string) Options::get('aap_custom_base_url', '')) !== '';
}
}
@@ -0,0 +1,12 @@
<?php
namespace AAP\Modules\Ai\Providers;
defined('ABSPATH') || exit;
final class NvidiaProvider extends OpenAiCompatible {
public function id(): string { return 'nvidia'; }
public function label(): string { return 'NVIDIA Build'; }
public function default_model(): string { return 'nvidia/nemotron-3-ultra-550b-a55b'; }
protected function base_url(): string { return 'https://integrate.api.nvidia.com/v1'; }
}
@@ -0,0 +1,96 @@
<?php
namespace AAP\Modules\Ai\Providers;
use AAP\Modules\Ai\Provider;
use AAP\Modules\Logs\Logger;
defined('ABSPATH') || exit;
/**
* Shared implementation for every OpenAI-compatible /chat/completions API
* (NVIDIA Build, OpenAI, OpenRouter and custom endpoints).
*/
abstract class OpenAiCompatible implements Provider {
protected string $api_key;
protected string $model;
protected int $timeout;
public function __construct(string $api_key, string $model, int $timeout) {
$this->api_key = trim($api_key);
$this->model = trim($model) !== '' ? trim($model) : $this->default_model();
$this->timeout = max(10, $timeout);
}
abstract protected function base_url(): string;
/** @return array<string,string> */
protected function extra_headers(): array {
return [];
}
public function is_configured(): bool {
return $this->api_key !== '';
}
public function complete(string $prompt, int $max_tokens = 4096, float $temperature = 0.7): ?string {
if (!$this->is_configured()) {
Logger::error('AI provider "' . $this->id() . '" is not configured (missing API key).', 'ai');
return null;
}
$url = rtrim($this->base_url(), '/') . '/chat/completions';
$response = wp_remote_post($url, [
'timeout' => $this->timeout,
'headers' => array_merge([
'Authorization' => 'Bearer ' . $this->api_key,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
], $this->extra_headers()),
'body' => wp_json_encode([
'model' => $this->model,
'messages' => [['role' => 'user', 'content' => $prompt]],
'temperature' => $temperature,
'max_tokens' => $max_tokens,
'stream' => false,
]),
]);
if (is_wp_error($response)) {
Logger::warn('AI request failed (' . $this->id() . '): ' . $response->get_error_message(), 'ai');
return null;
}
$code = (int) wp_remote_retrieve_response_code($response);
$body = (string) wp_remote_retrieve_body($response);
if ($code < 200 || $code >= 300) {
Logger::warn('AI HTTP ' . $code . ' (' . $this->id() . '): ' . substr($body, 0, 300), 'ai');
return null;
}
$data = json_decode($body, true);
$content = $data['choices'][0]['message']['content'] ?? null;
// Reasoning models (e.g. Nemotron, o1-style) spend the token budget on
// "reasoning_content" first. If that is exhausted, the API still answers
// 200 but leaves "content" null and stops with finish_reason "length".
if (!is_string($content) || trim($content) === '') {
$finish = (string) ($data['choices'][0]['finish_reason'] ?? '');
$reason = strlen((string) ($data['choices'][0]['message']['reasoning_content'] ?? ''));
$tokens = (string) ($data['usage']['completion_tokens'] ?? '?');
Logger::warn(
'AI returned empty content (' . $this->id() . "): finish_reason={$finish}, "
. "completion_tokens={$tokens}, reasoning_chars={$reason}"
. ($finish === 'length' ? ' — the token budget was consumed by reasoning; raise max_tokens.' : ''),
'ai'
);
return null;
}
return trim($content);
}
}
@@ -0,0 +1,12 @@
<?php
namespace AAP\Modules\Ai\Providers;
defined('ABSPATH') || exit;
final class OpenAiProvider extends OpenAiCompatible {
public function id(): string { return 'openai'; }
public function label(): string { return 'OpenAI'; }
public function default_model(): string { return 'gpt-4o-mini'; }
protected function base_url(): string { return 'https://api.openai.com/v1'; }
}
@@ -0,0 +1,19 @@
<?php
namespace AAP\Modules\Ai\Providers;
defined('ABSPATH') || exit;
final class OpenRouterProvider extends OpenAiCompatible {
public function id(): string { return 'openrouter'; }
public function label(): string { return 'OpenRouter'; }
public function default_model(): string { return 'openai/gpt-4o-mini'; }
protected function base_url(): string { return 'https://openrouter.ai/api/v1'; }
protected function extra_headers(): array {
return [
'HTTP-Referer' => home_url('/'),
'X-Title' => 'Automatic AI Posts',
];
}
}
@@ -0,0 +1,152 @@
<?php
namespace AAP\Modules\Ai\Providers;
use AAP\Modules\Ai\Provider;
use AAP\Modules\Logs\Logger;
defined('ABSPATH') || exit;
/**
* Google Vertex AI (Gemini) using a service account JSON key.
*
* Flow: service account JSON -> signed JWT -> OAuth access token -> generateContent.
*/
final class VertexProvider implements Provider {
private string $project;
private string $location;
private string $service_account;
private string $model;
private int $timeout;
public function __construct(string $project, string $location, string $service_account, string $model, int $timeout) {
$this->project = trim($project);
$this->location = trim($location) !== '' ? trim($location) : 'us-central1';
$this->service_account = trim($service_account);
$this->model = trim($model) !== '' ? trim($model) : $this->default_model();
$this->timeout = max(10, $timeout);
}
public function id(): string { return 'vertex'; }
public function label(): string { return 'Google Vertex AI'; }
public function default_model(): string { return 'gemini-2.0-flash'; }
public function is_configured(): bool {
return $this->project !== '' && $this->service_account !== '';
}
public function complete(string $prompt, int $max_tokens = 4096, float $temperature = 0.7): ?string {
if (!$this->is_configured()) {
Logger::error('Vertex AI is not configured (project or service account missing).', 'ai');
return null;
}
$token = $this->access_token();
if ($token === null) {
return null;
}
$url = sprintf(
'https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:generateContent',
rawurlencode($this->location),
rawurlencode($this->project),
rawurlencode($this->location),
rawurlencode($this->model)
);
$response = wp_remote_post($url, [
'timeout' => $this->timeout,
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => wp_json_encode([
'contents' => [
['role' => 'user', 'parts' => [['text' => $prompt]]],
],
'generationConfig' => [
'temperature' => $temperature,
'maxOutputTokens' => $max_tokens,
],
]),
]);
if (is_wp_error($response)) {
Logger::warn('Vertex request failed: ' . $response->get_error_message(), 'ai');
return null;
}
$code = (int) wp_remote_retrieve_response_code($response);
$body = (string) wp_remote_retrieve_body($response);
if ($code < 200 || $code >= 300) {
Logger::warn('Vertex HTTP ' . $code . ': ' . substr($body, 0, 300), 'ai');
return null;
}
$data = json_decode($body, true);
$content = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
return is_string($content) && trim($content) !== '' ? trim($content) : null;
}
private function access_token(): ?string {
$cache_key = 'aap_vertex_token_' . md5($this->project . $this->service_account);
$cached = get_transient($cache_key);
if (is_string($cached) && $cached !== '') {
return $cached;
}
$sa = json_decode($this->service_account, true);
if (!is_array($sa) || empty($sa['client_email']) || empty($sa['private_key'])) {
Logger::error('Vertex service account JSON is invalid.', 'ai');
return null;
}
$now = time();
$header = ['alg' => 'RS256', 'typ' => 'JWT'];
$claims = [
'iss' => $sa['client_email'],
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $now,
'exp' => $now + 3600,
];
$b64 = static fn(array $data): string => rtrim(strtr(base64_encode((string) wp_json_encode($data)), '+/', '-_'), '=');
$input = $b64($header) . '.' . $b64($claims);
$signature = '';
if (!openssl_sign($input, $signature, $sa['private_key'], OPENSSL_ALGO_SHA256)) {
Logger::error('Vertex: could not sign the JWT with the service account key.', 'ai');
return null;
}
$jwt = $input . '.' . rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
$response = wp_remote_post('https://oauth2.googleapis.com/token', [
'timeout' => $this->timeout,
'body' => [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
],
]);
if (is_wp_error($response)) {
Logger::warn('Vertex token request failed: ' . $response->get_error_message(), 'ai');
return null;
}
$data = json_decode((string) wp_remote_retrieve_body($response), true);
$token = $data['access_token'] ?? null;
if (!is_string($token) || $token === '') {
Logger::warn('Vertex token response without access_token.', 'ai');
return null;
}
set_transient($cache_key, $token, 55 * MINUTE_IN_SECONDS);
return $token;
}
}
@@ -0,0 +1,20 @@
<?php
namespace AAP\Modules\Content;
use AAP\Core\Module;
defined('ABSPATH') || exit;
/**
* Full article extraction from the source page.
*/
final class ContentModule extends Module {
public function id(): string {
return 'content';
}
public function dependencies(): array {
return ['logs'];
}
}
@@ -0,0 +1,87 @@
<?php
namespace AAP\Modules\Content;
use AAP\Modules\Logs\Logger;
defined('ABSPATH') || exit;
/**
* Extracts the main article text from the original page.
*/
final class Scraper {
public function scrape(string $url): ?string {
if ($url === '') {
return null;
}
$response = wp_remote_get($url, [
'timeout' => 15,
'user-agent' => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
'headers' => ['Accept-Language' => 'pt-BR,pt;q=0.9,en;q=0.8'],
]);
if (is_wp_error($response)) {
Logger::warn('Scraper request failed: ' . $response->get_error_message(), 'content');
return null;
}
if ((int) wp_remote_retrieve_response_code($response) !== 200) {
return null;
}
$html = (string) wp_remote_retrieve_body($response);
return $html !== '' ? $this->extract($html) : null;
}
private function extract(string $html): ?string {
libxml_use_internal_errors(true);
$doc = new \DOMDocument();
$doc->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_NOWARNING | LIBXML_NOERROR);
libxml_clear_errors();
$xpath = new \DOMXPath($doc);
foreach ($xpath->query('//script | //style | //nav | //header | //footer | //aside | //form | //iframe') as $node) {
$node->parentNode?->removeChild($node);
}
$queries = [
'//*[@itemprop="articleBody"]',
'//article',
'//*[contains(@class,"article-body")]',
'//*[contains(@class,"article-content")]',
'//*[contains(@class,"articleBody")]',
'//*[contains(@class,"post-content")]',
'//*[contains(@class,"entry-content")]',
'//*[contains(@class,"story-body")]',
'//*[contains(@class,"content-body")]',
'//*[contains(@class,"materia-conteudo")]',
'//*[contains(@class,"body-content")]',
'//main',
];
foreach ($queries as $query) {
$nodes = $xpath->query($query);
if (!$nodes || $nodes->length === 0) {
continue;
}
$text = $this->to_text($nodes->item(0));
if (strlen($text) >= 300) {
return $text;
}
}
return null;
}
private function to_text(\DOMNode $node): string {
$text = (string) ($node->textContent ?? '');
$text = (string) preg_replace('/[ \t]+/', ' ', $text);
$text = (string) preg_replace('/\n{3,}/', "\n\n", $text);
return trim($text);
}
}
@@ -0,0 +1,142 @@
<?php
namespace AAP\Modules\Editor;
use AAP\Core\Module;
use AAP\Modules\Images\ImageService;
use AAP\Modules\I18n\Lang;
use AAP\Modules\Logs\Logger;
defined('ABSPATH') || exit;
/**
* "Search a new cover image" button on the post edit screen.
*
* Runs the same steps as the pipeline (AI query + metadata, DuckDuckGo search,
* sideload) but on demand for a post that already exists. Useful when the
* automatically chosen image is not good enough.
*/
final class EditorModule extends Module {
public function id(): string {
return 'editor';
}
public function dependencies(): array {
return ['logs', 'ai', 'images', 'posts'];
}
public function boot(): void {
add_action('add_meta_boxes', [$this, 'meta_box']);
add_action('admin_enqueue_scripts', [$this, 'assets']);
add_action('wp_ajax_aap_refresh_image', [$this, 'ajax_refresh_image']);
}
public function meta_box(): void {
$screen = get_current_screen();
if (!$screen || $screen->base !== 'post') {
return;
}
add_meta_box(
'aap-refresh-image',
Lang::t('editor_box_title'),
[$this, 'render_meta_box'],
$screen->post_type,
'side',
'default'
);
}
public function render_meta_box($post): void {
$has_image = has_post_thumbnail($post->ID);
?>
<p class="aap-refresh-desc">
<?php echo esc_html(Lang::t('editor_desc')); ?>
</p>
<button type="button" class="button button-secondary aap-refresh-btn" id="aap-refresh-image-btn"
data-post="<?php echo esc_attr((string) $post->ID); ?>"
data-nonce="<?php echo esc_attr(wp_create_nonce('aap_refresh_image')); ?>">
<?php echo esc_html(Lang::t($has_image ? 'editor_btn_has_image' : 'editor_btn_no_image')); ?>
</button>
<span class="aap-refresh-feedback" id="aap-refresh-image-feedback" role="status" aria-live="polite"></span>
<?php
}
public function assets(string $hook): void {
if ($hook !== 'post.php' && $hook !== 'post-new.php') {
return;
}
wp_enqueue_style('aap-editor', AAP_PLUGIN_URL . 'admin/editor.css', [], AAP_VERSION);
wp_enqueue_script('aap-editor', AAP_PLUGIN_URL . 'admin/editor.js', ['jquery'], AAP_VERSION, true);
wp_localize_script('aap-editor', 'aapEditor', [
'ajaxurl' => admin_url('admin-ajax.php'),
'i18n' => [
'working' => Lang::t('editor_working'),
'success' => Lang::t('editor_success'),
'no_image' => Lang::t('editor_no_image'),
'error' => Lang::t('editor_error'),
],
]);
}
public function ajax_refresh_image(): void {
check_ajax_referer('aap_refresh_image', 'nonce');
$post_id = (int) ($_POST['post_id'] ?? 0);
if ($post_id <= 0 || !current_user_can('edit_post', $post_id)) {
wp_send_json_error(['message' => 'forbidden'], 403);
}
$post = get_post($post_id);
if (!$post) {
wp_send_json_error(['message' => 'missing_post'], 404);
}
$ai = new \AAP\Modules\Ai\AiService();
if (!$ai->is_ready()) {
Logger::error("Image refresh for post {$post_id}: AI provider is not configured.", 'images');
wp_send_json_error(['message' => 'ai_not_configured']);
}
$title = (string) get_the_title($post);
$content = (string) $post->post_content;
$language = \AAP\Support\Languages::current_post();
Logger::info("Manual image refresh requested for post {$post_id}: \"{$title}\".", 'images');
$images = new ImageService($ai);
$plan = $images->resolve($title, $content, $language);
if (empty($plan['urls'])) {
Logger::warn("Image refresh for post {$post_id}: no candidate image found.", 'images');
wp_send_json_success(['updated' => false, 'reason' => 'no_image']);
}
$attachment_id = $images->attach($plan['urls'], $post_id, $title, $plan['meta']);
if ($attachment_id <= 0) {
Logger::warn("Image refresh for post {$post_id}: could not download any candidate.", 'images');
wp_send_json_success(['updated' => false, 'reason' => 'download_failed']);
}
set_post_thumbnail($post_id, $attachment_id);
Logger::info(
"Cover image of post {$post_id} replaced (attachment ID {$attachment_id}, query \"{$plan['query']}\").",
'images'
);
wp_send_json_success([
'updated' => true,
'attachment_id' => $attachment_id,
'query' => $plan['query'],
'thumbnail' => (string) get_the_post_thumbnail_url($post_id, 'medium'),
]);
}
}
@@ -0,0 +1,20 @@
<?php
namespace AAP\Modules\Feeds;
use AAP\Core\Module;
defined('ABSPATH') || exit;
/**
* RSS + GNews article sources.
*/
final class FeedsModule extends Module {
public function id(): string {
return 'feeds';
}
public function dependencies(): array {
return ['logs', 'i18n'];
}
}
@@ -0,0 +1,134 @@
<?php
namespace AAP\Modules\Feeds;
use AAP\Core\Options;
use AAP\Modules\Logs\Logger;
use AAP\Support\Languages;
defined('ABSPATH') || exit;
/**
* Fetches headlines from the GNews.io API.
*/
final class GnewsFetcher {
private const API_URL = 'https://gnews.io/api/v4/top-headlines';
public const CATEGORIES = [
'general' => 'General',
'world' => 'World',
'nation' => 'Nation',
'business' => 'Business',
'technology' => 'Technology',
'entertainment' => 'Entertainment',
'sports' => 'Sports',
'science' => 'Science',
'health' => 'Health',
];
/**
* @return array<int, array{title:string,content:string,link:string,source:string}>
*/
public function fetch_all(): array {
$api_key = (string) Options::get('aap_gnews_api_key', '');
$categories = (array) Options::get('aap_gnews_categories', []);
$max = max(1, Options::int('aap_gnews_max'));
if ($api_key === '') {
Logger::warn('GNews: API key not configured, skipping.', 'feeds');
return [];
}
if (empty($categories)) {
Logger::warn('GNews: no category selected, skipping.', 'feeds');
return [];
}
$articles = [];
$first = true;
foreach ($categories as $category) {
if (!isset(self::CATEGORIES[$category])) {
continue;
}
if (!$first) {
sleep(2); // free plan rate limit
}
$first = false;
$articles = array_merge($articles, $this->fetch_category($api_key, (string) $category, $max));
}
return $articles;
}
private function fetch_category(string $api_key, string $category, int $max): array {
[$lang, $country] = Languages::gnews(Languages::current_post());
$url = add_query_arg([
'category' => $category,
'lang' => $lang,
'country' => $country,
'max' => $max,
'apikey' => $api_key,
], self::API_URL);
Logger::info("GNews: fetching \"{$category}\" (lang={$lang}, country={$country}, max={$max})", 'feeds');
$response = wp_remote_get($url, ['timeout' => 15]);
if (is_wp_error($response)) {
Logger::error("GNews request failed ({$category}): " . $response->get_error_message(), 'feeds');
return [];
}
$code = (int) wp_remote_retrieve_response_code($response);
$body = (string) wp_remote_retrieve_body($response);
if ($code === 429) {
Logger::warn("GNews rate limit reached ({$category}); retrying in 10s.", 'feeds');
sleep(10);
$response = wp_remote_get($url, ['timeout' => 15]);
if (!is_wp_error($response)) {
$code = (int) wp_remote_retrieve_response_code($response);
$body = (string) wp_remote_retrieve_body($response);
}
}
if ($code !== 200) {
Logger::error("GNews HTTP {$code} ({$category}): " . substr($body, 0, 200), 'feeds');
return [];
}
$data = json_decode($body, true);
if (!isset($data['articles']) || !is_array($data['articles'])) {
Logger::error("GNews response without \"articles\" ({$category}).", 'feeds');
return [];
}
$articles = [];
foreach ($data['articles'] as $item) {
$title = trim((string) ($item['title'] ?? ''));
$content = trim((string) ($item['content'] ?? $item['description'] ?? ''));
$link = trim((string) ($item['url'] ?? ''));
$source = trim((string) ($item['source']['name'] ?? 'GNews'));
if ($title === '' || $content === '') {
continue;
}
$articles[] = [
'title' => $title,
'content' => $content,
'link' => $link,
'source' => $source,
];
}
Logger::info("GNews \"{$category}\": " . count($articles) . ' article(s) collected.', 'feeds');
return $articles;
}
}
@@ -0,0 +1,139 @@
<?php
namespace AAP\Modules\Feeds;
use AAP\Core\Options;
use AAP\Modules\Logs\Logger;
defined('ABSPATH') || exit;
/**
* Fetches and normalizes RSS/Atom feeds.
*/
final class RssFetcher {
/**
* @param string[] $feed_urls
* @return array<int, array{title:string,content:string,link:string,source:string}>
*/
public function fetch_all(array $feed_urls): array {
$articles = [];
$max_items = max(1, Options::int('aap_feed_max_items'));
foreach ($feed_urls as $url) {
$articles = array_merge($articles, $this->fetch_feed($url, $max_items));
}
return $articles;
}
private function fetch_feed(string $url, int $max_items): array {
Logger::info("Fetching feed: {$url} (max {$max_items} items)", 'feeds');
$response = wp_remote_get($url, ['timeout' => 15]);
if (is_wp_error($response)) {
Logger::error("Feed request failed ({$url}): " . $response->get_error_message(), 'feeds');
return [];
}
$body = (string) wp_remote_retrieve_body($response);
if ($body === '') {
Logger::warn("Feed returned an empty body: {$url}", 'feeds');
return [];
}
return $this->parse($body, $url, $max_items);
}
private function parse(string $xml_body, string $source_url, int $max_items): array {
libxml_use_internal_errors(true);
$xml = simplexml_load_string($xml_body);
libxml_clear_errors();
if (!$xml) {
Logger::error("Could not parse feed XML: {$source_url}", 'feeds');
return [];
}
// Atom feeds wrap entries in <entry> at the root instead of <item> inside <channel>.
$is_atom = $xml->getName() === 'feed';
if ($is_atom) {
$source_name = $this->clean((string) ($xml->title ?? '')) ?: parse_url($source_url, PHP_URL_HOST);
$items = $xml->entry;
} else {
$source_name = (string) ($xml->channel->title ?? parse_url($source_url, PHP_URL_HOST));
$items = $xml->channel->item ?? [];
}
$articles = [];
foreach ($items as $item) {
if (count($articles) >= $max_items) {
break;
}
if ($is_atom) {
$title = $this->clean((string) $item->title);
$link = $this->atom_link($item);
$desc = $this->clean((string) $item->summary);
$full = $this->clean((string) $item->content);
} else {
$title = $this->clean((string) $item->title);
$link = $this->clean((string) $item->link);
$desc = $this->clean((string) $item->description);
$content_ns = $item->children('content', true);
$full = $content_ns ? $this->clean((string) $content_ns->encoded) : '';
}
$content = $full !== '' ? $full : $desc;
if ($title === '' || $content === '') {
continue;
}
$articles[] = [
'title' => $title,
'content' => $content,
'link' => $link,
'source' => $source_name,
];
}
Logger::info("Feed \"{$source_name}\": " . count($articles) . ' article(s) collected.', 'feeds');
return $articles;
}
/**
* Atom entries carry several <link> tags; the alternate one is the article URL.
*/
private function atom_link(\SimpleXMLElement $entry): string {
$fallback = '';
foreach ($entry->link as $link) {
$href = (string) $link['href'];
if ($href === '') {
continue;
}
$rel = (string) $link['rel'];
if ($rel === 'alternate' || $rel === '') {
return esc_url_raw($href);
}
if ($fallback === '') {
$fallback = $href;
}
}
return $fallback !== '' ? esc_url_raw($fallback) : $this->clean((string) $entry->id);
}
private function clean(string $text): string {
$text = wp_strip_all_tags($text);
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim($text);
}
}
@@ -0,0 +1,20 @@
<?php
namespace AAP\Modules\I18n;
use AAP\Core\Module;
defined('ABSPATH') || exit;
/**
* Provides translated admin strings (see Lang).
*/
final class I18nModule extends Module {
public function id(): string {
return 'i18n';
}
public function boot(): void {
Lang::load();
}
}
@@ -0,0 +1,70 @@
<?php
namespace AAP\Modules\I18n;
defined('ABSPATH') || exit;
/**
* Loads the admin UI strings from languages/{code}.php.
*
* The app language is independent from the WordPress locale, so the user can
* run the panel in Portuguese while the site is in English. Missing keys fall
* back to English.
*/
final class Lang {
private static array $strings = [];
private static array $fallback = [];
private static bool $loaded = false;
private static string $locale = '';
public static function load(): void {
if (self::$loaded) {
return;
}
self::$loaded = true;
self::$locale = \AAP\Support\Languages::current_app();
$dir = AAP_PLUGIN_DIR . 'languages/';
$fallback_file = $dir . 'en.php';
if (is_readable($fallback_file)) {
$data = require $fallback_file;
self::$fallback = is_array($data) ? $data : [];
}
$file = $dir . self::$locale . '.php';
if (is_readable($file)) {
$data = require $file;
self::$strings = is_array($data) ? $data : [];
}
}
public static function locale(): string {
self::load();
return self::$locale;
}
public static function t(string $key): string {
self::load();
$value = self::$strings[$key] ?? self::$fallback[$key] ?? $key;
return is_string($value) ? $value : $key;
}
/** @return array<string,mixed> */
public static function ta(string $key): array {
self::load();
$value = self::$strings[$key] ?? self::$fallback[$key] ?? [];
return is_array($value) ? $value : [];
}
/** @return array<string,mixed> */
public static function all(): array {
self::load();
return array_merge(self::$fallback, self::$strings);
}
}
@@ -0,0 +1,111 @@
<?php
namespace AAP\Modules\Images;
use AAP\Modules\Logs\Logger;
defined('ABSPATH') || exit;
/**
* DuckDuckGo image search (no API key required).
*
* The query itself is produced by the AI (see AiService::image_plan), so the
* blog owner's image instructions influence what is searched.
*/
final class DuckDuckGo {
private int $timeout;
public function __construct(int $timeout = 30) {
$this->timeout = max(10, $timeout);
}
/**
* @return string[] image URLs (best first)
*/
public function search(string $query, int $limit = 10): array {
$query = trim($query);
if ($query === '') {
return [];
}
$encoded = urlencode($query);
$token = $this->vqd($encoded);
if ($token === '') {
Logger::warn('DuckDuckGo: vqd token not found for "' . $query . '".', 'images');
return [];
}
return $this->fetch($encoded, $token, $limit);
}
private function vqd(string $encoded_query): string {
$response = wp_remote_get(
'https://duckduckgo.com/?q=' . $encoded_query . '&iax=images&ia=images',
[
'timeout' => $this->timeout,
'user-agent' => 'Mozilla/5.0 (compatible; automatic-ai-posts/2.0)',
]
);
if (is_wp_error($response)) {
Logger::warn('DuckDuckGo: token request failed: ' . $response->get_error_message(), 'images');
return '';
}
$body = (string) wp_remote_retrieve_body($response);
if (preg_match('/vqd=([\d-]+)/', $body, $m)) {
return $m[1];
}
return '';
}
/**
* @return string[]
*/
private function fetch(string $encoded_query, string $token, int $limit): array {
$url = add_query_arg([
'l' => 'us-en',
'o' => 'json',
'q' => $encoded_query,
'vqd' => $token,
'f' => ',,,,,',
'p' => '1',
], 'https://duckduckgo.com/i.js');
$response = wp_remote_get($url, [
'timeout' => $this->timeout,
'headers' => [
'Referer' => 'https://duckduckgo.com/',
'User-Agent' => 'Mozilla/5.0 (compatible; automatic-ai-posts/2.0)',
],
]);
if (is_wp_error($response)) {
Logger::warn('DuckDuckGo: image request failed: ' . $response->get_error_message(), 'images');
return [];
}
$data = json_decode((string) wp_remote_retrieve_body($response), true);
if (!isset($data['results']) || !is_array($data['results'])) {
Logger::warn('DuckDuckGo: response without results.', 'images');
return [];
}
$urls = [];
foreach ($data['results'] as $result) {
$image = (string) ($result['image'] ?? '');
if ($image !== '') {
$urls[] = $image;
if (count($urls) >= $limit) {
break;
}
}
}
return $urls;
}
}
@@ -0,0 +1,63 @@
<?php
namespace AAP\Modules\Images;
use AAP\Core\Options;
use AAP\Modules\Ai\AiService;
use AAP\Modules\Logs\Logger;
defined('ABSPATH') || exit;
/**
* Image pipeline: AI builds the search query + metadata, DuckDuckGo finds
* candidates, Sideloader stores the first one that downloads.
*/
final class ImageService {
private AiService $ai;
private DuckDuckGo $search;
private Sideloader $sideloader;
public function __construct(?AiService $ai = null) {
// Image requests get their own (short) timeout: the AI timeout can be
// several minutes, which would hang the pipeline on a slow image host.
$this->ai = $ai ?? new AiService();
$this->search = new DuckDuckGo(20);
$this->sideloader = new Sideloader(45);
}
/**
* @return array{query:string,urls:string[],meta:array{alt:string,caption:string,description:string}}
*/
public function resolve(string $title, string $content, string $language): array {
$plan = $this->ai->image_plan($title, $content, $language);
$query = $plan['query'] ?? $title;
Logger::info("Image search query: \"{$query}\"", 'images');
$urls = $this->search->search($query);
// Fallback: the AI query may be too specific; retry with the article title.
if (empty($urls) && $query !== $title) {
Logger::warn('No image found for the AI query; retrying with the article title.', 'images');
$urls = $this->search->search($title);
}
$alt = trim((string) ($plan['alt'] ?? ''));
$meta = [
'alt' => $alt !== '' ? $alt : $title,
'caption' => (string) ($plan['caption'] ?? ''),
'description' => (string) ($plan['description'] ?? ''),
];
return [
'query' => $query,
'urls' => $urls,
'meta' => $meta,
];
}
public function attach(array $urls, int $post_id, string $title, array $meta): int {
return $this->sideloader->attach($urls, $post_id, $title, $meta);
}
}
@@ -0,0 +1,20 @@
<?php
namespace AAP\Modules\Images;
use AAP\Core\Module;
defined('ABSPATH') || exit;
/**
* Cover image search (DuckDuckGo) and sideloading.
*/
final class ImagesModule extends Module {
public function id(): string {
return 'images';
}
public function dependencies(): array {
return ['logs', 'ai'];
}
}
@@ -0,0 +1,90 @@
<?php
namespace AAP\Modules\Images;
use AAP\Modules\Logs\Logger;
defined('ABSPATH') || exit;
/**
* Downloads a remote image into the media library and applies its metadata.
*/
final class Sideloader {
private int $timeout;
public function __construct(int $timeout = 60) {
$this->timeout = max(10, $timeout);
}
/**
* Tries every URL until one downloads successfully.
*
* @param string[] $urls
* @param array{alt:string,caption:string,description:string}|null $meta
*/
public function attach(array $urls, int $post_id, string $title, ?array $meta = null): int {
if (empty($urls)) {
return 0;
}
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$total = count($urls);
foreach (array_values($urls) as $index => $url) {
$attempt = $index + 1;
Logger::info("Downloading cover image ({$attempt}/{$total}): {$url}", 'images');
$tmp = download_url($url, $this->timeout);
if (is_wp_error($tmp)) {
Logger::warn("Image attempt {$attempt} failed: " . $tmp->get_error_message(), 'images');
continue;
}
$file = [
'name' => sanitize_file_name($title) . '.' . $this->extension($url),
'tmp_name' => $tmp,
];
$attachment_id = media_handle_sideload($file, $post_id);
if (is_wp_error($attachment_id)) {
@unlink($tmp);
Logger::warn("Image attempt {$attempt} failed on sideload: " . $attachment_id->get_error_message(), 'images');
continue;
}
Logger::info("Cover image saved in the media library (ID {$attachment_id}).", 'images');
if (!empty($meta)) {
wp_update_post([
'ID' => $attachment_id,
'post_excerpt' => (string) ($meta['caption'] ?? ''),
'post_content' => (string) ($meta['description'] ?? ''),
]);
if (!empty($meta['alt'])) {
update_post_meta($attachment_id, '_wp_attachment_image_alt', (string) $meta['alt']);
}
Logger::info('Image metadata applied (alt, caption, description).', 'images');
}
return (int) $attachment_id;
}
Logger::warn("Could not download any of the {$total} candidate image(s).", 'images');
return 0;
}
private function extension(string $url): string {
$path = parse_url($url, PHP_URL_PATH);
$ext = strtolower(pathinfo((string) $path, PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
return in_array($ext, $allowed, true) ? $ext : 'jpg';
}
}
@@ -0,0 +1,121 @@
<?php
namespace AAP\Modules\Logs;
defined('ABSPATH') || exit;
/**
* Persists logs in the database (table {prefix}aap_logs).
*
* Levels:
* info — normal operation
* warn — recoverable error (a step failed but the pipeline continued)
* error — unrecoverable error (the step/run could not continue)
*/
final class LogRepository {
public const TABLE_VERSION = '1';
public const RETENTION_DAYS = 30;
public static function table(): string {
global $wpdb;
return $wpdb->prefix . 'aap_logs';
}
public static function install(): void {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = self::table();
$collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
created_at DATETIME NOT NULL,
level VARCHAR(10) NOT NULL DEFAULT 'info',
message TEXT NOT NULL,
context VARCHAR(64) NULL,
PRIMARY KEY (id),
KEY created_at (created_at),
KEY level (level)
) {$collate};";
dbDelta($sql);
update_option('aap_logs_table_version', self::TABLE_VERSION);
}
public static function maybe_install(): void {
if (get_option('aap_logs_table_version') !== self::TABLE_VERSION) {
self::install();
}
}
public static function add(string $level, string $message, string $context = ''): void {
global $wpdb;
$level = in_array($level, ['info', 'warn', 'error'], true) ? $level : 'info';
$wpdb->insert(
self::table(),
[
'created_at' => current_time('mysql'),
'level' => $level,
'message' => $message,
'context' => $context !== '' ? substr($context, 0, 64) : null,
],
['%s', '%s', '%s', '%s']
);
}
/**
* @return array{rows: array<int, object>, total: int, has_more: bool}
*/
public static function page(int $page = 1, int $per_page = 50, string $level = ''): array {
global $wpdb;
$page = max(1, $page);
$per_page = min(200, max(1, $per_page));
$offset = ($page - 1) * $per_page;
$table = self::table();
$where = '';
$params = [];
if (in_array($level, ['info', 'warn', 'error'], true)) {
$where = 'WHERE level = %s';
$params[] = $level;
}
$count_sql = "SELECT COUNT(*) FROM {$table} {$where}";
$total = (int) ($params
? $wpdb->get_var($wpdb->prepare($count_sql, $params))
: $wpdb->get_var($count_sql));
$list_sql = "SELECT id, created_at, level, message, context FROM {$table} {$where} ORDER BY id DESC LIMIT %d OFFSET %d";
$args = array_merge($params, [$per_page, $offset]);
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $args));
return [
'rows' => is_array($rows) ? $rows : [],
'total' => $total,
'has_more' => ($offset + count((array) $rows)) < $total,
];
}
/** Removes entries older than the retention window. */
public static function purge(): int {
global $wpdb;
$limit = wp_date('Y-m-d H:i:s', time() - (self::RETENTION_DAYS * DAY_IN_SECONDS));
return (int) $wpdb->query(
$wpdb->prepare('DELETE FROM ' . self::table() . ' WHERE created_at < %s', $limit)
);
}
public static function total(): int {
global $wpdb;
return (int) $wpdb->get_var('SELECT COUNT(*) FROM ' . self::table());
}
}
@@ -0,0 +1,36 @@
<?php
namespace AAP\Modules\Logs;
defined('ABSPATH') || exit;
/**
* Static logging facade used across every module.
*
* Everything that happens in the pipeline is written here: feed fetches,
* AI calls, image downloads, post creation, scheduling and admin actions.
*/
final class Logger {
public static function info(string $message, string $context = ''): void {
self::write('info', $message, $context);
}
public static function warn(string $message, string $context = ''): void {
self::write('warn', $message, $context);
}
public static function error(string $message, string $context = ''): void {
self::write('error', $message, $context);
}
private static function write(string $level, string $message, string $context): void {
// Never let logging break the pipeline.
try {
LogRepository::add($level, $message, $context);
} catch (\Throwable $e) {
if (function_exists('error_log')) {
error_log('[Automatic AI Posts] log failure: ' . $e->getMessage());
}
}
}
}
@@ -0,0 +1,26 @@
<?php
namespace AAP\Modules\Logs;
use AAP\Core\Module;
defined('ABSPATH') || exit;
/**
* Database-backed logging with a 30 day retention window.
*/
final class LogsModule extends Module {
public function id(): string {
return 'logs';
}
public function boot(): void {
LogRepository::maybe_install();
// Retention: runs at most once a day.
if (!get_transient('aap_logs_purged')) {
LogRepository::purge();
set_transient('aap_logs_purged', 1, DAY_IN_SECONDS);
}
}
}
@@ -0,0 +1,264 @@
<?php
namespace AAP\Modules\Pipeline;
use AAP\Core\Options;
use AAP\Modules\Ai\AiService;
use AAP\Modules\Content\Scraper;
use AAP\Modules\Feeds\GnewsFetcher;
use AAP\Modules\Feeds\RssFetcher;
use AAP\Modules\Images\ImageService;
use AAP\Modules\Logs\Logger;
use AAP\Modules\Posts\PostCreator;
use AAP\Support\Languages;
defined('ABSPATH') || exit;
/**
* Orchestrates one full run:
* fetch -> AI selection -> (scrape -> rewrite -> category -> image -> post) x N
*/
final class Pipeline {
private AiService $ai;
private RssFetcher $rss;
private GnewsFetcher $gnews;
private Scraper $scraper;
private ImageService $images;
private PostCreator $posts;
public function __construct() {
$this->ai = new AiService();
$this->rss = new RssFetcher();
$this->gnews = new GnewsFetcher();
$this->scraper = new Scraper();
$this->images = new ImageService($this->ai);
$this->posts = new PostCreator($this->images);
}
public function run(int $author_id = 0): void {
Logger::info('=== Pipeline started ===', 'pipeline');
if (!$this->ai->is_ready()) {
Logger::error('AI provider is not configured. Pipeline aborted.', 'pipeline');
return;
}
$language = Languages::current_post();
$min = max(1, Options::int('aap_min_posts'));
$max = max($min, Options::int('aap_max_posts'));
$post_status = (string) Options::get('aap_post_status', 'draft');
Logger::info(
'Settings: provider=' . $this->ai->provider_label()
. " | language={$language} | posts={$min}-{$max} | status={$post_status} | author={$author_id}",
'pipeline'
);
$articles = $this->collect_articles();
if (empty($articles)) {
Logger::error('No articles collected from RSS/GNews. Pipeline aborted.', 'pipeline');
return;
}
$recent = $this->recent_titles();
if ($recent) {
Logger::info(count($recent) . ' recent post title(s) loaded to avoid duplicates.', 'pipeline');
}
// Deterministic exclusion (models often ignore the negative profile).
$blocked_terms = TopicFilter::terms((string) Options::get('aap_negative_profile', ''));
if ($blocked_terms) {
$before = count($articles);
$articles = TopicFilter::filter($articles, $blocked_terms);
$removed = $before - count($articles);
if ($removed > 0) {
Logger::info("{$removed} article(s) removed by the exclusion list.", 'pipeline');
}
}
Logger::info('Asking the AI to select the best articles...', 'pipeline');
$selected = $this->ai->select_topics($articles, $min, $max, $recent);
if (empty($selected)) {
Logger::error('AI selected no articles. Pipeline aborted.', 'pipeline');
return;
}
if ($blocked_terms) {
$before = count($selected);
$selected = TopicFilter::filter($selected, $blocked_terms);
if (count($selected) < $before) {
Logger::warn('AI selected excluded topic(s); removed by the exclusion list.', 'pipeline');
}
}
if (empty($selected)) {
Logger::error('No article left after applying the exclusion list. Pipeline aborted.', 'pipeline');
return;
}
Logger::info(count($selected) . ' article(s) selected by the AI.', 'pipeline');
$categories = $this->blog_categories();
if ($categories) {
Logger::info(count($categories) . ' blog category(ies) available for the AI.', 'pipeline');
}
$total = count($selected);
foreach ($selected as $index => $article) {
if (RunState::consume_abort()) {
Logger::warn('=== Run aborted by the user ===', 'pipeline');
break;
}
RunState::touch();
$num = $index + 1;
Logger::info("[{$num}/{$total}] Processing: \"{$article['title']}\"", 'pipeline');
$this->process_article($article, $num, $language, $post_status, $author_id, $categories);
wp_cache_flush();
}
update_option('aap_last_run', current_time('mysql'));
Logger::info('=== Pipeline finished ===', 'pipeline');
}
/** @return array<int, array{title:string,content:string,link:string,source:string}> */
private function collect_articles(): array {
$feeds = array_filter(array_map('trim', explode("\n", (string) Options::get('aap_feeds', ''))));
$rss_articles = [];
if ($feeds) {
Logger::info('Fetching ' . count($feeds) . ' RSS feed(s)...', 'pipeline');
$rss_articles = $this->rss->fetch_all($feeds);
} else {
Logger::info('No RSS feed configured; skipping.', 'pipeline');
}
$gnews_articles = $this->gnews->fetch_all();
Logger::info(
count($rss_articles) . ' from RSS + ' . count($gnews_articles) . ' from GNews = '
. (count($rss_articles) + count($gnews_articles)) . ' total.',
'pipeline'
);
return array_merge($rss_articles, $gnews_articles);
}
/**
* @param array{title:string,content:string,link:string,source:string} $article
* @param array<int,string> $categories
*/
private function process_article(
array $article,
int $num,
string $language,
string $post_status,
int $author_id,
array $categories
): void {
if (!empty($article['link'])) {
Logger::info("[{$num}] Scraping full content: {$article['link']}", 'pipeline');
$scraped = $this->scraper->scrape($article['link']);
if (!empty($scraped)) {
Logger::info("[{$num}] Full content extracted (" . mb_strlen($scraped) . ' chars).', 'pipeline');
$article['content'] = $scraped;
} else {
Logger::warn("[{$num}] Could not scrape the page; using the feed content.", 'pipeline');
}
}
Logger::info("[{$num}] Rewriting with AI...", 'pipeline');
$rewritten = $this->ai->rewrite($article, $language);
if ($rewritten === null) {
Logger::error("[{$num}] Rewrite failed; skipping this article.", 'pipeline');
return;
}
Logger::info("[{$num}] Rewritten: \"{$rewritten['title']}\"", 'pipeline');
$category_id = 0;
if ($categories) {
$category_id = $this->ai->choose_category($rewritten['title'], $rewritten['content'], $categories, $language);
if ($category_id > 0) {
Logger::info("[{$num}] Category: {$categories[$category_id]} (ID {$category_id})", 'pipeline');
} else {
Logger::warn("[{$num}] AI did not choose a category; using the WordPress default.", 'pipeline');
}
}
Logger::info("[{$num}] Searching a cover image...", 'pipeline');
$image = $this->images->resolve($rewritten['title'], $rewritten['content'], $language);
if (empty($image['urls'])) {
Logger::warn("[{$num}] No cover image found; the post will be saved without one.", 'pipeline');
} else {
Logger::info("[{$num}] " . count($image['urls']) . ' image candidate(s) found.', 'pipeline');
}
Logger::info("[{$num}] Creating the post (status: {$post_status})...", 'pipeline');
$post_id = $this->posts->create(
$rewritten['title'],
$rewritten['content'],
$image['urls'],
$image['meta'],
$author_id,
$category_id,
$post_status
);
if ($post_id > 0) {
$label = $post_status === 'publish' ? 'Published' : 'Draft created';
Logger::info("[{$num}] {$label} successfully (post ID {$post_id}).", 'pipeline');
} else {
Logger::error("[{$num}] Could not save the post.", 'pipeline');
}
}
/** @return string[] */
private function recent_titles(): array {
$posts = get_posts([
'post_status' => ['publish', 'draft'],
'posts_per_page' => 50,
'orderby' => 'date',
'order' => 'DESC',
'fields' => 'all',
]);
return array_map(static fn($post) => $post->post_title, $posts);
}
/** @return array<int,string> id => name */
private function blog_categories(): array {
$terms = get_terms(['taxonomy' => 'category', 'hide_empty' => false]);
if (is_wp_error($terms) || empty($terms)) {
return [];
}
$default = (int) get_option('default_category', 1);
$map = [];
foreach ($terms as $term) {
if ((int) $term->term_id === $default) {
continue;
}
$map[(int) $term->term_id] = $term->name;
}
return $map;
}
}
@@ -0,0 +1,102 @@
<?php
namespace AAP\Modules\Pipeline;
use AAP\Core\Module;
use AAP\Modules\Logs\Logger;
defined('ABSPATH') || exit;
/**
* Runs the pipeline in a separate PHP process so the admin request returns
* immediately and long runs are not killed by the web server timeout.
*/
final class PipelineModule extends Module {
public function id(): string {
return 'pipeline';
}
public function dependencies(): array {
return ['logs', 'i18n', 'ai', 'feeds', 'content', 'images', 'posts'];
}
public function boot(): void {
add_action('aap_run_pipeline_cli', [$this, 'run_cli'], 10, 1);
}
/** Entry point used by the CLI bootstrapper (bin/run-pipeline.php). */
public function run_cli(int $author_id = 0): void {
Logger::info('Background process started (PID ' . getmypid() . ').', 'pipeline');
RunState::start();
try {
(new Pipeline())->run($author_id);
} catch (\Throwable $e) {
Logger::error(
'Unhandled exception: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine(),
'pipeline'
);
} finally {
RunState::stop();
update_option('aap_last_run', current_time('mysql'));
Logger::info('=== Pipeline process finished ===', 'pipeline');
}
}
/** Spawns the background process. Returns false when it cannot start. */
public function spawn(int $user_id): bool {
if (!function_exists('shell_exec')) {
Logger::error('shell_exec is disabled; cannot start the background process.', 'pipeline');
return false;
}
$php = $this->php_binary();
if ($php === '') {
Logger::error('PHP CLI binary not found; cannot start the background process.', 'pipeline');
return false;
}
$script = AAP_PLUGIN_DIR . 'bin/run-pipeline.php';
$stderr = WP_CONTENT_DIR . '/uploads/automatic-ai-posts-stderr.log';
$cmd = escapeshellarg($php) . ' ' . escapeshellarg($script) . ' ' . (int) $user_id
. ' >> ' . escapeshellarg($stderr) . ' 2>&1 & echo $!';
$pid = (int) trim((string) shell_exec($cmd));
if ($pid <= 0) {
Logger::error('Could not start the background process (invalid PID).', 'pipeline');
return false;
}
RunState::start();
update_option('aap_pipeline_pid', $pid);
Logger::info("Pipeline started in background (PID {$pid}, author {$user_id}).", 'pipeline');
return true;
}
private function php_binary(): string {
$candidate = PHP_BINARY;
if ($candidate !== '' && is_executable($candidate) && $this->is_php($candidate)) {
return $candidate;
}
foreach (['/usr/local/bin/php', '/usr/bin/php', '/usr/bin/php8', '/usr/bin/php82', '/usr/bin/php83'] as $path) {
if (is_executable($path) && $this->is_php($path)) {
return $path;
}
}
$found = trim((string) shell_exec('which php 2>/dev/null'));
return ($found !== '' && is_executable($found)) ? $found : '';
}
private function is_php(string $path): bool {
$out = shell_exec(escapeshellarg($path) . ' -r "echo \'ok\';" 2>/dev/null');
return trim((string) $out) === 'ok';
}
}
@@ -0,0 +1,60 @@
<?php
namespace AAP\Modules\Pipeline;
defined('ABSPATH') || exit;
/**
* Shared "is a run happening?" state (transients + option), used by the
* pipeline, the admin AJAX handlers and the scheduler.
*/
final class RunState {
/** 20 minutes, in seconds. */
private const TTL = 1200;
public static function is_running(): bool {
return (bool) get_transient('aap_is_processing');
}
public static function started_at(): int {
return (int) get_option('aap_processing_started_at', 0);
}
/** A run that never finished (process died) must not block forever. */
public static function is_stale(): bool {
$started = self::started_at();
return $started > 0 && (time() - $started) > 25 * MINUTE_IN_SECONDS;
}
public static function start(): void {
// A previous abort must not cancel the new run.
delete_transient('aap_abort_requested');
set_transient('aap_is_processing', true, self::TTL);
update_option('aap_processing_started_at', time(), false);
}
public static function touch(): void {
set_transient('aap_is_processing', true, self::TTL);
}
public static function stop(): void {
delete_transient('aap_is_processing');
delete_transient('aap_abort_requested');
delete_option('aap_processing_started_at');
delete_option('aap_pipeline_pid');
}
public static function request_abort(): void {
set_transient('aap_abort_requested', true, self::TTL);
}
/** Returns true once per abort request (consumes the flag). */
public static function consume_abort(): bool {
if (get_transient('aap_abort_requested')) {
delete_transient('aap_abort_requested');
return true;
}
return false;
}
}
@@ -0,0 +1,98 @@
<?php
namespace AAP\Modules\Pipeline;
defined('ABSPATH') || exit;
/**
* Deterministic exclusion filter built from the "negative profile" text.
*
* Models do not always honour the exclusion list, so articles matching the
* configured topics are removed before and after the AI selection.
*/
final class TopicFilter {
private const STOPWORDS = [
'nunca', 'nao', 'não', 'evite', 'evitar', 'selecione', 'selecionar',
'nunca selecione', 'não selecione', 'nao selecione',
'never', 'avoid', 'select', 'do not', 'not', 'the', 'and', 'or',
'ou', 'e', 'de', 'da', 'do', 'das', 'dos', 'com', 'para', 'por',
'sobre', 'temas', 'tema', 'conteudo', 'conteúdo', 'artigos', 'artigo',
'noticias', 'notícias', 'qualquer', 'qualquer coisa',
];
/** Lowercase + accent-free, so "política" also matches "politica" in URLs. */
private static function normalize(string $text): string {
$text = function_exists('remove_accents') ? remove_accents($text) : $text;
return mb_strtolower($text);
}
/** @return string[] lowercase terms */
public static function terms(string $negative_profile): array {
$text = self::normalize(trim($negative_profile));
if ($text === '') {
return [];
}
// Remove instruction words so only the topics remain.
$text = preg_replace('/\b(nunca|não|nao|evite|evitar|selecione|selecionar|never|avoid|select)\b/u', ' ', $text);
$parts = preg_split('/[,;\.\n\/]+|\s+ou\s+|\s+or\s+/u', (string) $text) ?: [];
$terms = [];
foreach ($parts as $part) {
$part = trim($part, " \t\n\r.:;-\"'()[]");
if ($part === '' || mb_strlen($part) < 4) {
continue;
}
$part = self::normalize($part);
if (in_array($part, self::STOPWORDS, true)) {
continue;
}
$terms[$part] = true;
}
return array_keys($terms);
}
/**
* @param array{title:string,content:string,link:string,source:string} $article
* @param string[] $terms
*/
public static function is_blocked(array $article, array $terms): bool {
if (empty($terms)) {
return false;
}
$haystack = self::normalize($article['title'] . ' ' . $article['link']);
foreach ($terms as $term) {
if ($term !== '' && mb_strpos($haystack, $term) !== false) {
return true;
}
}
return false;
}
/**
* @param array<int, array> $articles
* @param string[] $terms
* @return array<int, array>
*/
public static function filter(array $articles, array $terms): array {
if (empty($terms)) {
return $articles;
}
return array_values(array_filter(
$articles,
static fn(array $article): bool => !self::is_blocked($article, $terms)
));
}
}
@@ -0,0 +1,71 @@
<?php
namespace AAP\Modules\Posts;
use AAP\Modules\Images\ImageService;
use AAP\Modules\Logs\Logger;
defined('ABSPATH') || exit;
/**
* Creates the WordPress post and attaches the cover image.
*/
final class PostCreator {
private ImageService $images;
public function __construct(?ImageService $images = null) {
$this->images = $images ?? new ImageService();
}
/**
* @param string[] $image_urls
* @param array{alt:string,caption:string,description:string}|null $image_meta
*/
public function create(
string $title,
string $content,
array $image_urls = [],
?array $image_meta = null,
int $author_id = 0,
int $category_id = 0,
string $post_status = 'draft'
): int {
$post_data = [
'post_title' => $title,
'post_content' => $content,
'post_status' => in_array($post_status, ['draft', 'publish'], true) ? $post_status : 'draft',
'post_author' => $author_id > 0 ? $author_id : $this->admin_id(),
];
if ($category_id > 0) {
$post_data['post_category'] = [$category_id];
}
$post_id = wp_insert_post($post_data, true);
if (is_wp_error($post_id)) {
Logger::error('Could not create the post: ' . $post_id->get_error_message(), 'posts');
return 0;
}
if (!empty($image_urls)) {
$attachment_id = $this->images->attach(
$image_urls,
(int) $post_id,
$title,
$image_meta ?? ['alt' => $title, 'caption' => '', 'description' => '']
);
if ($attachment_id > 0) {
set_post_thumbnail((int) $post_id, $attachment_id);
}
}
return (int) $post_id;
}
private function admin_id(): int {
$admins = get_users(['role' => 'administrator', 'number' => 1, 'fields' => 'ID']);
return !empty($admins) ? (int) $admins[0] : 1;
}
}
@@ -0,0 +1,20 @@
<?php
namespace AAP\Modules\Posts;
use AAP\Core\Module;
defined('ABSPATH') || exit;
/**
* WordPress post creation and featured image assignment.
*/
final class PostsModule extends Module {
public function id(): string {
return 'posts';
}
public function dependencies(): array {
return ['logs', 'images'];
}
}
@@ -0,0 +1,42 @@
<?php
namespace AAP\Modules\Schedule;
use AAP\Core\Module;
defined('ABSPATH') || exit;
/**
* Automatic generation scheduling.
*/
final class ScheduleModule extends Module {
private ?Scheduler $scheduler = null;
public function id(): string {
return 'schedule';
}
public function dependencies(): array {
return ['logs', 'pipeline'];
}
public function boot(): void {
$this->scheduler = new Scheduler();
add_action('shutdown', [$this, 'on_shutdown']);
add_action('aap_cron_tick', [$this->scheduler, 'tick']);
add_action('update_option_aap_auto_interval_value', [$this->scheduler, 'reschedule']);
add_action('update_option_aap_auto_interval_unit', [$this->scheduler, 'reschedule']);
}
/** Runs on every web request, after the response was sent. */
public function on_shutdown(): void {
if (php_sapi_name() !== 'cli') {
$this->scheduler?->tick();
}
}
public function scheduler(): ?Scheduler {
return $this->scheduler;
}
}
@@ -0,0 +1,111 @@
<?php
namespace AAP\Modules\Schedule;
use AAP\Core\Options;
use AAP\Core\Registry;
use AAP\Modules\Logs\Logger;
use AAP\Modules\Pipeline\PipelineModule;
use AAP\Modules\Pipeline\RunState;
defined('ABSPATH') || exit;
/**
* Own scheduler (no WP-Cron): stores the next run timestamp and spawns the
* pipeline when the time comes.
*/
final class Scheduler {
public static function interval_seconds(): int {
$value = max(1, Options::int('aap_auto_interval_value'));
$unit = (string) Options::get('aap_auto_interval_unit', 'hours');
return match ($unit) {
'minutes' => $value * MINUTE_IN_SECONDS,
'days' => $value * DAY_IN_SECONDS,
default => $value * HOUR_IN_SECONDS,
};
}
public function start(string $mode, int $user_id, int $first_run_at = 0): void {
update_option('aap_auto_enabled', true);
update_option('aap_auto_user_id', $user_id);
$label = Options::int('aap_auto_interval_value') . ' ' . Options::get('aap_auto_interval_unit', 'hours');
if ($mode === 'now') {
update_option('aap_next_auto_run', time() + self::interval_seconds());
Logger::info("Automatic generation enabled (user {$user_id}, interval {$label}). Running now.", 'schedule');
$this->spawn($user_id);
return;
}
$first = ($first_run_at > 0 && $first_run_at > time())
? $first_run_at
: time() + self::interval_seconds();
update_option('aap_next_auto_run', $first);
Logger::info(
"Automatic generation enabled (user {$user_id}, interval {$label}). First run: " . wp_date('d/m/Y H:i', $first) . '.',
'schedule'
);
}
public function stop(): void {
update_option('aap_auto_enabled', false);
delete_option('aap_next_auto_run');
delete_transient('aap_auto_spawn_lock');
wp_clear_scheduled_hook('aap_auto_run');
Logger::info('Automatic generation paused.', 'schedule');
}
public function reschedule(): void {
if (!Options::bool('aap_auto_enabled')) {
return;
}
update_option('aap_next_auto_run', time() + self::interval_seconds());
Logger::info('Interval changed; next run rescheduled.', 'schedule');
}
/** Called on every web request (shutdown) and by the system cron. */
public function tick(): void {
if (!Options::bool('aap_auto_enabled')) {
return;
}
if (RunState::is_running() && !RunState::is_stale()) {
return;
}
$next = (int) get_option('aap_next_auto_run', 0);
if ($next === 0 || time() < $next) {
return;
}
// Atomic-ish lock: avoid double spawn in concurrent requests.
if (get_transient('aap_auto_spawn_lock')) {
return;
}
set_transient('aap_auto_spawn_lock', 1, 60);
$next_ts = time() + self::interval_seconds();
update_option('aap_next_auto_run', $next_ts);
$author = (int) get_option('aap_auto_user_id', 0);
Logger::info('Automatic run triggered by the scheduler. Next: ' . wp_date('d/m/Y H:i', $next_ts) . '.', 'schedule');
$this->spawn($author);
}
private function spawn(int $user_id): bool {
$pipeline = Registry::get('pipeline');
if (!$pipeline instanceof PipelineModule) {
Logger::error('Pipeline module unavailable; cannot start a run.', 'schedule');
return false;
}
return $pipeline->spawn($user_id);
}
}
@@ -0,0 +1,69 @@
<?php
namespace AAP\Support;
defined('ABSPATH') || exit;
/**
* Inline SVG icons used by the admin UI (stroke style, 24x24).
*/
final class Icons {
public static function svg(string $name, string $class = ''): string {
$paths = self::paths()[$name] ?? self::paths()['info'];
$attrs = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"'
. ' stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"';
if ($class !== '') {
$attrs .= ' class="' . esc_attr($class) . '"';
}
return '<svg ' . $attrs . '>' . $paths . '</svg>';
}
public static function logo(string $class = ''): string {
$class_attr = $class !== '' ? ' class="' . esc_attr($class) . '"' : '';
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"'
. ' stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"' . $class_attr . '>'
. '<rect x="3.5" y="8" width="12" height="9.5" rx="3"/>'
. '<path d="M7.6 17.5v2.2M11.4 17.5v2.2"/>'
. '<path d="M6.6 12.2h.01M9.9 12.2h.01"/>'
. '<path d="M14.5 6.5V6"/>'
. '<circle cx="17.6" cy="4.6" r="1.05"/>'
. '<circle cx="17.6" cy="9.2" r="1.05"/>'
. '<path d="M15.6 9.2h1.2M18.4 9.2h1.2"/>'
. '</svg>';
}
private static function paths(): array {
return [
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'play' => '<polygon points="7 4.5 19 12 7 19.5 7 4.5"/>',
'list' => '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
'help' => '<circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.5 2.5 0 0 1 4.85.85c0 1.65-2.45 2.45-2.45 2.45"/><line x1="12" y1="16.6" x2="12.01" y2="16.6"/>',
'chevron-down' => '<polyline points="6 9 12 15 18 9"/>',
'caret' => '<polyline points="6 9 12 15 18 9"/>',
'rss' => '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1.2"/>',
'gnews' => '<path d="M4 6.5A2.5 2.5 0 0 1 6.5 4H19a1 1 0 0 1 1 1v13.5A1.5 1.5 0 0 1 18.5 20h-12A2.5 2.5 0 0 1 4 17.5z"/><line x1="7.5" y1="8.5" x2="14.5" y2="8.5"/><line x1="7.5" y1="12" x2="14.5" y2="12"/><line x1="7.5" y1="15.5" x2="11" y2="15.5"/>',
'ai' => '<rect x="5" y="7" width="14" height="12" rx="3"/><circle cx="9.5" cy="13" r="1"/><circle cx="14.5" cy="13" r="1"/><line x1="12" y1="3.5" x2="12" y2="7"/><circle cx="12" cy="3" r="1"/><line x1="2.5" y1="11" x2="5" y2="11"/><line x1="19" y1="11" x2="21.5" y2="11"/>',
'info' => '<circle cx="12" cy="12" r="9"/><line x1="12" y1="11" x2="12" y2="16.5"/><line x1="12" y1="7.8" x2="12.01" y2="7.8"/>',
'check' => '<polyline points="20 6.5 9.5 17 4 11.5"/>',
'trash' => '<polyline points="3.5 6.5 20.5 6.5"/><path d="M18.5 6.5V19a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2V6.5"/><path d="M9 6.5V4.5a1.5 1.5 0 0 1 1.5-1.5h3A1.5 1.5 0 0 1 15 4.5v2"/>',
'clock' => '<circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/>',
'image' => '<rect x="3" y="4" width="18" height="16" rx="2.5"/><circle cx="9" cy="9.5" r="1.6"/><path d="m3.5 17.5 4.7-4.7a2 2 0 0 1 2.8 0l5.5 5.5"/><path d="m14.5 14.5 1.8-1.8a2 2 0 0 1 2.8 0l1.4 1.4"/>',
'save' => '<path d="M5 3.5h11.5L20.5 7v13a.5.5 0 0 1-.5.5H4a.5.5 0 0 1-.5-.5V4a.5.5 0 0 1 .5-.5z"/><polyline points="8 3.5 8 9.5 16 9.5 16 3.5"/><rect x="7.5" y="13.5" width="9" height="7"/>',
'refresh' => '<path d="M20.5 12a8.5 8.5 0 1 1-2.6-6.1"/><polyline points="20.5 4 20.5 9.5 15 9.5"/>',
'square' => '<rect x="6" y="6" width="12" height="12" rx="2"/>',
'external' => '<path d="M14 4.5h5.5V10"/><path d="M19 5 10.5 13.5"/><path d="M18.5 14.5v4a1.5 1.5 0 0 1-1.5 1.5H5.5A1.5 1.5 0 0 1 4 18.5V7a1.5 1.5 0 0 1 1.5-1.5h4"/>',
'heart' => '<path d="M12 20s-7.5-4.6-7.5-9.5A4.3 4.3 0 0 1 12 7.6a4.3 4.3 0 0 1 7.5 2.9C19.5 15.4 12 20 12 20z"/>',
'shield' => '<path d="M12 3.5 19.5 6v6c0 4.4-3.2 7.6-7.5 8.5C7.7 19.6 4.5 16.4 4.5 12V6z"/><polyline points="8.8 11.8 11.2 14.2 15.4 10"/>',
'key' => '<circle cx="8" cy="15" r="4"/><path d="m11 12 8-8 2 2-2 2 2 2-3 3-2-2-2 2"/>',
'globe' => '<circle cx="12" cy="12" r="9"/><path d="M3.5 12h17"/><path d="M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"/>',
'database' => '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6"/><path d="M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>',
'star' => '<polygon points="12 3.5 14.6 8.8 20.5 9.6 16.2 13.7 17.3 19.5 12 16.7 6.7 19.5 7.8 13.7 3.5 9.6 9.4 8.8"/>',
'code' => '<polyline points="9 7 4 12 9 17"/><polyline points="15 7 20 12 15 17"/>',
'pix' => '<path d="M12 3.5 20.5 12 12 20.5 3.5 12z"/><path d="M8.5 12h7"/>',
'user' => '<circle cx="12" cy="8" r="3.5"/><path d="M4.5 20c0-3.6 3.4-6 7.5-6s7.5 2.4 7.5 6"/>',
'book' => '<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H19v15.5H6.5A2.5 2.5 0 0 0 4 21z"/><path d="M4 18.5A2.5 2.5 0 0 1 6.5 16H19"/>',
'bolt' => '<polygon points="13 3 5 13 11 13 10 21 19 10 12.5 10 13 3"/>',
'mail' => '<rect x="3" y="5" width="18" height="14" rx="2.5"/><polyline points="4 7 12 13 20 7"/>',
];
}
}
@@ -0,0 +1,68 @@
<?php
namespace AAP\Support;
defined('ABSPATH') || exit;
/**
* Supported languages.
*
* - UI languages (app): files in languages/{code}.php
* - Post languages: used to instruct the AI and to query GNews
*/
final class Languages {
/** code => [native label, english name (for prompts), gnews lang, gnews country] */
public const POST = [
'pt-BR' => ['Português (Brasil)', 'Brazilian Portuguese', 'pt', 'br'],
'en-US' => ['English (US)', 'English', 'en', 'us'],
'es-ES' => ['Español', 'Spanish', 'es', 'es'],
'fr-FR' => ['Français', 'French', 'fr', 'fr'],
'de-DE' => ['Deutsch', 'German', 'de', 'de'],
'it-IT' => ['Italiano', 'Italian', 'it', 'it'],
'ja-JP' => ['日本語', 'Japanese', 'ja', 'jp'],
'zh-CN' => ['中文', 'Chinese (Simplified)', 'zh', 'cn'],
'ru-RU' => ['Русский', 'Russian', 'ru', 'ru'],
'ar-SA' => ['العربية', 'Arabic', 'ar', 'sa'],
'hi-IN' => ['हिन्दी', 'Hindi', 'hi', 'in'],
];
/** UI languages available in languages/ */
public const APP = [
'pt_BR' => 'Português (Brasil)',
'en' => 'English',
'es' => 'Español',
'fr' => 'Français',
'de' => 'Deutsch',
'it' => 'Italiano',
];
/** @return array<string,string> code => native label */
public static function post_options(): array {
$out = [];
foreach (self::POST as $code => $data) {
$out[$code] = $data[0];
}
return $out;
}
/** English name used inside prompts (the AI must answer in this language). */
public static function prompt_name(string $code): string {
return self::POST[$code][1] ?? 'English';
}
/** GNews lang/country pair. */
public static function gnews(string $code): array {
$data = self::POST[$code] ?? self::POST['pt-BR'];
return [$data[2], $data[3]];
}
public static function current_post(): string {
$code = (string) \AAP\Core\Options::get('aap_post_language', 'pt-BR');
return isset(self::POST[$code]) ? $code : 'pt-BR';
}
public static function current_app(): string {
$code = (string) \AAP\Core\Options::get('aap_app_language', 'pt_BR');
return isset(self::APP[$code]) ? $code : 'pt_BR';
}
}
@@ -35,7 +35,7 @@ final class GSO_Portfolio_Settings {
return array(
'hero_eyebrow' => __( 'Desenvolvimento de ponta a ponta', 'gustavoo-portfolio-core' ),
'hero_title' => __( 'Transformo ideias em produtos digitais sólidos.', 'gustavoo-portfolio-core' ),
'hero_description' => __( 'Gustavo, Desenvolvedor Full Stack Sênior. Web, mobile, infraestrutura e automação para tirar projetos do papel e fazê-los crescer.', 'gustavoo-portfolio-core' ),
'hero_description' => __( 'Gustavo, Desenvolvedor Full Stack. Web, mobile, infraestrutura e automação para tirar projetos do papel e fazê-los crescer.', 'gustavoo-portfolio-core' ),
'hero_primary_label' => __( 'Ver projetos', 'gustavoo-portfolio-core' ),
'hero_primary_url' => '#projetos',
'hero_secondary_label' => __( 'Fale comigo', 'gustavoo-portfolio-core' ),
@@ -72,6 +72,7 @@ final class GSO_Seeder {
* @return void
*/
public static function maybe_seed() {
self::normalize_sidebar_widgets();
self::seed_site_configuration();
$state = self::get_state();
@@ -158,12 +159,103 @@ final class GSO_Seeder {
}
}
if ( empty( $state['title'] ) ) {
self::seed_site_title();
$state = is_array( $state ) ? $state : array();
$state['title'] = true;
update_option( self::SITE_OPTION, $state, false );
}
if ( empty( $state['widgets'] ) ) {
self::seed_theme_widgets();
$state = is_array( $state ) ? $state : array();
$state['widgets'] = true;
update_option( self::SITE_OPTION, $state, false );
}
if ( empty( $state['permalinks'] ) ) {
self::seed_permalinks();
$state = is_array( $state ) ? $state : array();
$state['permalinks'] = true;
update_option( self::SITE_OPTION, $state, false );
}
if ( empty( $state['avatars'] ) ) {
self::seed_avatars();
$state = is_array( $state ) ? $state : array();
$state['avatars'] = true;
update_option( self::SITE_OPTION, $state, false );
}
}
/**
* Use the "Day and name" permalink structure.
*
* The post URL then reads /2026/11/02/post-slug/, which keeps dates
* meaningful for a news-style blog. Only the stock "plain" structure is
* replaced, so a structure chosen by the owner is respected.
*
* @return void
*/
private static function seed_permalinks() {
if ( '' !== (string) get_option( 'permalink_structure' ) ) {
return;
}
update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' );
// Rebuild the rewrite rules and write the Apache block into .htaccess.
// `hard = true` is required: without it the rules stay cached in the DB
// and the web server never sees the new URLs (everything would 404).
global $wp_rewrite;
if ( $wp_rewrite instanceof WP_Rewrite ) {
$wp_rewrite->set_permalink_structure( '/%year%/%monthnum%/%day%/%postname%/' );
$wp_rewrite->flush_rules( true );
}
// flush_rules() only rewrites .htaccess when it can detect mod_rewrite,
// which needs an HTTP request context. Force the block in otherwise.
if ( function_exists( 'save_mod_rewrite_rules' ) ) {
save_mod_rewrite_rules();
}
}
/**
* Fall back to the "initials" avatar.
*
* Stand-in avatars avoid broken images and third-party requests on every
* comment. Only the stock "mystery" default is replaced.
*
* @return void
*/
private static function seed_avatars() {
if ( 'mystery' === (string) get_option( 'avatar_default' ) ) {
update_option( 'avatar_default', 'initials' );
}
// Gravatar images are remote; keep them enabled but never required.
if ( '' === (string) get_option( 'show_avatars' ) ) {
update_option( 'show_avatars', '1' );
}
}
/**
* Name the site with the full professional title used on the home page.
*
* WordPress fills the browser title of the front page from `blogname`, so
* this value is what search engines and tabs display. A name changed by the
* site owner is respected: only the stock install value is replaced.
*
* @return void
*/
private static function seed_site_title() {
$title = __( 'Gustavo Desenvolvedor Full Stack | Web, Mobile, Infraestrutura & Automação', 'gustavoo-portfolio-core' );
$name = (string) get_option( 'blogname' );
if ( '' === $name || in_array( $name, array( 'Gustavo Portfolio', 'My Blog', 'Just another WordPress site', 'WordPress' ), true ) ) {
update_option( 'blogname', $title );
}
}
/**
@@ -220,6 +312,15 @@ final class GSO_Seeder {
* @return void
*/
private static function seed_theme_widgets() {
/*
* WordPress stores a snapshot of the widget layout in the `sidebars_widgets`
* theme mod. Retrieve_widgets() reapplies that snapshot on every `init`
* whenever it exists, which reverts this seeding and re-introduces blocks
* that were moved to the inactive area. The option is the source of truth
* here, so a stale snapshot is dropped before reading the layout.
*/
remove_theme_mod( 'sidebars_widgets' );
$sidebars = wp_get_sidebars_widgets();
$sidebars = is_array( $sidebars ) ? $sidebars : array();
@@ -255,6 +356,55 @@ final class GSO_Seeder {
}
update_option( 'sidebars_widgets', $sidebars, false );
// The option is authoritative from now on; discarding the stale theme
// mod snapshot stops retrieve_widgets() from overriding it on `init`.
remove_theme_mod( 'sidebars_widgets' );
}
/**
* Repair widget areas that print the same widget more than once.
*
* Earlier versions persisted duplicate widget ids, so the same block was
* rendered twice in the sidebar and footer. Runs on every boot and only
* writes when a duplicate is actually found.
*
* @return void
*/
private static function normalize_sidebar_widgets() {
/*
* Read the raw option on purpose. wp_get_sidebars_widgets() merges the
* layout through retrieve_widgets(), which already drops duplicate
* ids from the array it returns. Relying on it would hide the
* duplicated entries and the repair below would never run.
*/
$sidebars = get_option( 'sidebars_widgets' );
if ( ! is_array( $sidebars ) ) {
return;
}
$changed = false;
foreach ( $sidebars as $sidebar_id => $widgets ) {
if ( ! is_array( $widgets ) ) {
continue;
}
$unique = array_values( array_unique( $widgets ) );
if ( count( $unique ) !== count( $widgets ) ) {
$sidebars[ $sidebar_id ] = $unique;
$changed = true;
}
}
if ( ! $changed ) {
return;
}
update_option( 'sidebars_widgets', $sidebars, false );
remove_theme_mod( 'sidebars_widgets' );
}
/**
@@ -28,7 +28,7 @@ $gustavoo_primary_shortcut_attr = is_customize_preview() ? ' data-customize-part
<div class="site-branding">
<?php gustavoo_portfolio_the_brand(); ?>
<a class="site-branding__text" href="<?php echo esc_url( home_url( '/' ) ); ?>" rel="home">
<span class="site-branding__name"><?php echo esc_html( get_bloginfo( 'name' ) ); ?></span>
<span class="site-branding__name"><?php echo esc_html( gustavoo_portfolio_brand_name() ); ?></span>
<span class="site-branding__tagline site-branding__tagline--full"><?php esc_html_e( 'Desenvolvedor Full Stack | Web, Mobile, Infraestrutura & Automação', 'gustavoo-portfolio' ); ?></span>
<span class="site-branding__tagline site-branding__tagline--short"><?php esc_html_e( 'Desenvolvedor Full Stack | Web, Mobile, Infraestrutura & Automação', 'gustavoo-portfolio' ); ?></span>
</a>
@@ -18,7 +18,7 @@ function gustavoo_portfolio_get_defaults() {
$defaults = array(
'hero_eyebrow' => __( 'Desenvolvimento de ponta a ponta', 'gustavoo-portfolio' ),
'hero_title' => __( 'Transformo ideias em produtos digitais sólidos.', 'gustavoo-portfolio' ),
'hero_description' => __( 'Gustavo, Desenvolvedor Full Stack Sênior. Web, mobile, infraestrutura e automação para tirar projetos do papel e fazê-los crescer.', 'gustavoo-portfolio' ),
'hero_description' => __( 'Gustavo, Desenvolvedor Full Stack. Web, mobile, infraestrutura e automação para tirar projetos do papel e fazê-los crescer.', 'gustavoo-portfolio' ),
'hero_primary_label' => __( 'Ver projetos', 'gustavoo-portfolio' ),
'hero_primary_url' => '#projetos',
'hero_secondary_label' => __( 'Fale comigo', 'gustavoo-portfolio' ),
@@ -82,6 +82,29 @@ function gustavoo_portfolio_the_brand() {
<?php
}
/**
* Short name shown next to the logo in the header.
*
* The full professional title lives in the site name (blogname), which becomes
* the browser title. The header only shows the first name, so set
* `gustavoo_portfolio_brand_name` in the Customizer to override it.
*
* @return string
*/
function gustavoo_portfolio_brand_name() {
$brand = get_theme_mod( 'gustavoo_portfolio_brand_name', '' );
$brand = is_string( $brand ) ? trim( $brand ) : '';
if ( '' !== $brand ) {
return $brand;
}
$name = (string) get_bloginfo( 'name' );
$name = strtok( $name, ' |' );
return $name ? $name : get_bloginfo( 'name' );
}
/**
* Return the appropriate URL for a front-page section.
*