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:
1 parent
5891467266
commit
0ea3651fb5
73 files changed
+8858
-39
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user