From 0ea3651fb53796fe483946f7d1418b12c921c545 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 2 Nov 2026 13:33:27 -0300 Subject: [PATCH] Automatic AI Posts, setup automatizado e seeder de permalinks/avatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 61 + .gitignore | 8 + README.md | 52 +- compose.yaml | 19 +- docker/wp-init.sh | 398 +++- .../automatic-ai-posts/admin/admin.css | 1712 +++++++++++++++++ .../plugins/automatic-ai-posts/admin/admin.js | 802 ++++++++ .../automatic-ai-posts/admin/editor.css | 22 + .../automatic-ai-posts/admin/editor.js | 57 + .../plugins/automatic-ai-posts/admin/icon.svg | 10 + .../automatic-ai-posts/admin/pix-qrcode.png | Bin 0 -> 136156 bytes .../automatic-ai-posts/automatic-ai-posts.php | 57 + .../automatic-ai-posts/bin/auto-check.php | 30 + .../automatic-ai-posts/bin/run-pipeline.php | 55 + .../automatic-ai-posts/languages/de.php | 103 + .../automatic-ai-posts/languages/en.php | 201 ++ .../automatic-ai-posts/languages/es.php | 103 + .../automatic-ai-posts/languages/fr.php | 103 + .../automatic-ai-posts/languages/it.php | 103 + .../automatic-ai-posts/languages/pt_BR.php | 201 ++ .../src/Core/Autoloader.php | 30 + .../automatic-ai-posts/src/Core/Module.php | 30 + .../automatic-ai-posts/src/Core/Options.php | 97 + .../automatic-ai-posts/src/Core/Registry.php | 88 + .../src/Modules/Admin/AdminModule.php | 257 +++ .../src/Modules/Admin/Ajax.php | 176 ++ .../src/Modules/Admin/Panels/HelpPanel.php | 155 ++ .../src/Modules/Admin/Panels/LogPanel.php | 58 + .../src/Modules/Admin/Panels/RunPanel.php | 157 ++ .../Modules/Admin/Panels/SettingsPanel.php | 419 ++++ .../src/Modules/Admin/SettingsPage.php | 172 ++ .../src/Modules/Ai/AiModule.php | 21 + .../src/Modules/Ai/AiService.php | 321 ++++ .../src/Modules/Ai/PromptLibrary.php | 164 ++ .../src/Modules/Ai/Provider.php | 28 + .../src/Modules/Ai/ProviderFactory.php | 62 + .../Ai/Providers/AnthropicProvider.php | 66 + .../Modules/Ai/Providers/CustomProvider.php | 25 + .../Modules/Ai/Providers/NvidiaProvider.php | 12 + .../Modules/Ai/Providers/OpenAiCompatible.php | 96 + .../Modules/Ai/Providers/OpenAiProvider.php | 12 + .../Ai/Providers/OpenRouterProvider.php | 19 + .../Modules/Ai/Providers/VertexProvider.php | 152 ++ .../src/Modules/Content/ContentModule.php | 20 + .../src/Modules/Content/Scraper.php | 87 + .../src/Modules/Editor/EditorModule.php | 142 ++ .../src/Modules/Feeds/FeedsModule.php | 20 + .../src/Modules/Feeds/GnewsFetcher.php | 134 ++ .../src/Modules/Feeds/RssFetcher.php | 139 ++ .../src/Modules/I18n/I18nModule.php | 20 + .../src/Modules/I18n/Lang.php | 70 + .../src/Modules/Images/DuckDuckGo.php | 111 ++ .../src/Modules/Images/ImageService.php | 63 + .../src/Modules/Images/ImagesModule.php | 20 + .../src/Modules/Images/Sideloader.php | 90 + .../src/Modules/Logs/LogRepository.php | 121 ++ .../src/Modules/Logs/Logger.php | 36 + .../src/Modules/Logs/LogsModule.php | 26 + .../src/Modules/Pipeline/Pipeline.php | 264 +++ .../src/Modules/Pipeline/PipelineModule.php | 102 + .../src/Modules/Pipeline/RunState.php | 60 + .../src/Modules/Pipeline/TopicFilter.php | 98 + .../src/Modules/Posts/PostCreator.php | 71 + .../src/Modules/Posts/PostsModule.php | 20 + .../src/Modules/Schedule/ScheduleModule.php | 42 + .../src/Modules/Schedule/Scheduler.php | 111 ++ .../automatic-ai-posts/src/Support/Icons.php | 69 + .../src/Support/Languages.php | 68 + .../includes/class-gso-portfolio-settings.php | 2 +- .../includes/class-gso-seeder.php | 150 ++ .../themes/gustavoo-portfolio/header.php | 2 +- .../gustavoo-portfolio/inc/defaults.php | 2 +- .../gustavoo-portfolio/inc/template-tags.php | 23 + 73 files changed, 8858 insertions(+), 39 deletions(-) create mode 100644 .env.example mode change 100644 => 100755 docker/wp-init.sh create mode 100644 wp-content/plugins/automatic-ai-posts/admin/admin.css create mode 100644 wp-content/plugins/automatic-ai-posts/admin/admin.js create mode 100644 wp-content/plugins/automatic-ai-posts/admin/editor.css create mode 100644 wp-content/plugins/automatic-ai-posts/admin/editor.js create mode 100644 wp-content/plugins/automatic-ai-posts/admin/icon.svg create mode 100644 wp-content/plugins/automatic-ai-posts/admin/pix-qrcode.png create mode 100644 wp-content/plugins/automatic-ai-posts/automatic-ai-posts.php create mode 100644 wp-content/plugins/automatic-ai-posts/bin/auto-check.php create mode 100644 wp-content/plugins/automatic-ai-posts/bin/run-pipeline.php create mode 100644 wp-content/plugins/automatic-ai-posts/languages/de.php create mode 100644 wp-content/plugins/automatic-ai-posts/languages/en.php create mode 100644 wp-content/plugins/automatic-ai-posts/languages/es.php create mode 100644 wp-content/plugins/automatic-ai-posts/languages/fr.php create mode 100644 wp-content/plugins/automatic-ai-posts/languages/it.php create mode 100644 wp-content/plugins/automatic-ai-posts/languages/pt_BR.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Core/Autoloader.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Core/Module.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Core/Options.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Core/Registry.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Admin/AdminModule.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Admin/Ajax.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Admin/Panels/HelpPanel.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Admin/Panels/LogPanel.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Admin/Panels/RunPanel.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Admin/Panels/SettingsPanel.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Admin/SettingsPage.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Ai/AiModule.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Ai/AiService.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Ai/PromptLibrary.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Provider.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Ai/ProviderFactory.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/AnthropicProvider.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/CustomProvider.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/NvidiaProvider.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/OpenAiCompatible.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/OpenAiProvider.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/OpenRouterProvider.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/VertexProvider.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Content/ContentModule.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Content/Scraper.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Editor/EditorModule.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Feeds/FeedsModule.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Feeds/GnewsFetcher.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Feeds/RssFetcher.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/I18n/I18nModule.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/I18n/Lang.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Images/DuckDuckGo.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Images/ImageService.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Images/ImagesModule.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Images/Sideloader.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Logs/LogRepository.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Logs/Logger.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Logs/LogsModule.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/Pipeline.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/PipelineModule.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/RunState.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/TopicFilter.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Posts/PostCreator.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Posts/PostsModule.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Schedule/ScheduleModule.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Modules/Schedule/Scheduler.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Support/Icons.php create mode 100644 wp-content/plugins/automatic-ai-posts/src/Support/Languages.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cd224cf --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 905be28..b048435 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 10d5736..23bff32 100644 --- a/README.md +++ b/README.md @@ -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.). diff --git a/compose.yaml b/compose.yaml index a519067..4d0ce18 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/docker/wp-init.sh b/docker/wp-init.sh old mode 100644 new mode 100755 index 6729abb..18d3c4f --- a/docker/wp-init.sh +++ b/docker/wp-init.sh @@ -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 diff --git a/wp-content/plugins/automatic-ai-posts/admin/admin.css b/wp-content/plugins/automatic-ai-posts/admin/admin.css new file mode 100644 index 0000000..56ea77a --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/admin/admin.css @@ -0,0 +1,1712 @@ +/** + * Automatic AI Posts — painel administrativo + * + * Todas as regras são escopadas em #aap-app para não afetar o restante do wp-admin. + */ + +#aap-app { + --aap-navy: #0f2038; + --aap-navy-2: #132a47; + --aap-blue: #2563eb; + --aap-blue-hover: #1d4ed8; + --aap-blue-dark: #1e40af; + --aap-blue-soft: #eff6ff; + --aap-border: #e2e8f0; + --aap-border-soft: #eef2f7; + --aap-bg: #f8fafc; + --aap-text: #0f172a; + --aap-muted: #64748b; + --aap-muted-2: #94a3b8; + --aap-green: #16a34a; + --aap-green-soft: #ecfdf3; + --aap-green-line: #bbf7d0; + --aap-red: #dc2626; + --aap-amber: #b45309; + + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif; + color: var(--aap-text); + font-size: 15px; + line-height: 1.55; + margin: 0; + -webkit-font-smoothing: antialiased; +} + +/* O painel ocupa toda a largura da área de conteúdo */ +#aap-app ~ .clear { display: none; } +body.no-js #aap-app .aap-panel { display: block; } + +/* Layout em tela cheia nesta página: sem limite de largura e sem o respiro + que o WordPress adiciona ao redor do conteúdo */ +body.toplevel_page_automatic-ai-posts #wpbody-content > .wrap, +body.toplevel_page_automatic-ai-posts #wpbody-content > .wrap > .wrap { + max-width: none; + margin: 0; + padding: 0; +} + +body.toplevel_page_automatic-ai-posts #wpbody-content { padding: 0; margin: 0; } + +/* O padding-left de 20px do #wpcontent deixa uma faixa cinza antes do painel. + É zerado, e o `box-sizing` mantém o conteúdo na largura correta — o recorte + lateral da barra do WordPress continua funcionando. */ +body.toplevel_page_automatic-ai-posts #wpcontent { + padding-left: 0; + box-sizing: border-box; +} + +/* Rodapé do WordPress oculto nesta página */ +body.toplevel_page_automatic-ai-posts #wpfooter { display: none; } + +/* Avisos de outros plugins não aparecem nesta página (quebram o layout do painel) */ +body.toplevel_page_automatic-ai-posts #wpbody-content .notice, +body.toplevel_page_automatic-ai-posts #wpbody-content .update-nag { + display: none !important; +} + +/* ─── Shell: sidebar + conteúdo ─────────────────────────────────────────── */ + +#aap-app .aap-shell { + display: grid; + grid-template-columns: 236px minmax(0, 1fr); + gap: 0; + align-items: stretch; +} + +/* ─── Sidebar ───────────────────────────────────────────────────────────── */ + +/* A coluna navy acompanha a altura do conteúdo (sem emenda visual), mas o + conteúdo dela fica preso na tela: a navegação não rola junto com a página. */ +#aap-app .aap-sidebar { + background: var(--aap-navy); + border-radius: 0; + padding: 0; + box-sizing: border-box; + min-width: 0; +} + +#aap-app .aap-sidebar-inner { + position: sticky; + top: 32px; + z-index: 10; + display: flex; + flex-direction: column; + height: calc(100vh - 32px); + padding: 18px 14px 14px; + box-sizing: border-box; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, .18) transparent; +} + +#aap-app .aap-sidebar-inner::-webkit-scrollbar { width: 6px; } +#aap-app .aap-sidebar-inner::-webkit-scrollbar-track { background: transparent; } +#aap-app .aap-sidebar-inner::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, .18); + border-radius: 3px; +} + +#aap-app .aap-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 0 6px 22px; +} + +#aap-app .aap-brand-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + flex: 0 0 38px; + color: #60a5fa; +} + +#aap-app .aap-brand-icon svg { width: 100%; height: 100%; display: block; } + +#aap-app .aap-brand-title { + color: #fff; + font-size: 15px; + line-height: 1.15; + font-weight: 700; + letter-spacing: -0.2px; +} + +#aap-app .aap-nav { display: flex; flex-direction: column; gap: 4px; } + +#aap-app .aap-nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 14px; + border-radius: 8px; + color: #cbd5e1; + text-decoration: none; + font-size: 13.5px; + font-weight: 500; + transition: background .15s ease, color .15s ease; + box-shadow: none; +} + +#aap-app .aap-nav-item .aap-nav-icon { + display: inline-flex; + width: 18px; + height: 18px; + flex: 0 0 18px; + color: #94a3b8; + transition: color .15s ease; +} + +#aap-app .aap-nav-item .aap-nav-icon svg { width: 18px; height: 18px; display: block; } + +#aap-app .aap-nav-item:hover, +#aap-app .aap-nav-item:focus { + background: rgba(255, 255, 255, .06); + color: #fff; + box-shadow: none; +} + +#aap-app .aap-nav-item:hover .aap-nav-icon, +#aap-app .aap-nav-item:focus .aap-nav-icon { color: #e2e8f0; } + +#aap-app .aap-nav-item.aap-active, +#aap-app .aap-nav-item.aap-active:hover, +#aap-app .aap-nav-item.aap-active:focus { + background: var(--aap-blue); + color: #fff; + font-weight: 600; +} + +#aap-app .aap-nav-item.aap-active .aap-nav-icon { color: #fff; } + +#aap-app .aap-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--aap-muted-2); + flex: 0 0 8px; + display: inline-block; +} + +#aap-app .aap-dot.is-on { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, .18); } + +/* ─── Painel principal ──────────────────────────────────────────────────── */ + +#aap-app .aap-main { min-width: 0; } + +#aap-app .aap-panel { + background: #fff; + border: 1px solid var(--aap-border); + border-left: 0; + border-radius: 0; + padding: 18px 20px 22px; + box-shadow: none; + min-height: calc(100vh - 90px); + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: clip; +} + +#aap-app .aap-panel[hidden] { display: none !important; } + +/* O conteúdo preenche a altura restante do painel (que é flex em coluna) */ +#aap-app .aap-panel > #aap-settings-form, +#aap-app .aap-panel > .aap-cards, +#aap-app .aap-panel > .aap-log-shell { flex: 1 1 auto; } + +/* ─── Cabeçalho fixo de cada aba ───────────────────────────────────────── */ +/* Fica sempre visível no topo, alinhado logo abaixo da barra do WordPress + (o offset exato é calculado em admin.js). A sidebar não é fixa. */ + +#aap-app .aap-sticky-head { + position: sticky; + top: 32px; + z-index: 15; + background: #fff; + margin: -18px -20px 0; + padding: 18px 20px 6px; + border-radius: 0; + transition: box-shadow .15s ease; +} + +#aap-app .aap-sticky-head::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 1px; + background: var(--aap-border); + opacity: 0; + transition: opacity .15s ease; +} + +#aap-app .aap-sticky-head.is-stuck { + box-shadow: 0 6px 14px -6px rgba(15, 23, 42, .28); +} + +/* Linha de corte do cabeçalho: deixa claro que o conteúdo passa por baixo */ +#aap-app .aap-sticky-head.is-stuck::after { opacity: 1; } + +#aap-app .aap-sticky-head > .aap-panel-head, +#aap-app .aap-sticky-head > .aap-settings-head { margin-bottom: 16px; } + +#aap-app .aap-sticky-head ~ * { scroll-margin-top: 260px; } + +/* ─── Cabeçalho do painel ──────────────────────────────────────────────── */ + +#aap-app .aap-panel-head { + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 18px; +} + +#aap-app .aap-panel-head-text { flex: 1; min-width: 0; } + +#aap-app .aap-title { + display: flex; + align-items: center; + gap: 12px; + margin: 0; + padding: 0; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.4px; + line-height: 1.25; + color: var(--aap-text); +} + +#aap-app .aap-title svg { width: 24px; height: 24px; color: var(--aap-blue); flex: 0 0 24px; } + +#aap-app .aap-subtitle { + margin: 4px 0 0; + font-size: 13px; + color: var(--aap-muted); +} + +/* Ícone redondo colorido (cabeçalhos de card, painéis) */ +#aap-app .aap-ico { + display: inline-grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 50%; + flex: 0 0 36px; + background: #eef2ff; + color: var(--aap-blue); +} + +#aap-app .aap-ico svg { width: 18px; height: 18px; display: block; } + +#aap-app .aap-ico--blue { background: #dbeafe; color: #1d4ed8; } +#aap-app .aap-ico--violet { background: #ede9fe; color: #7c3aed; } +#aap-app .aap-ico--green { background: #dcfce7; color: #15803d; } +#aap-app .aap-ico--orange { background: #ffedd5; color: #ea580c; } +#aap-app .aap-ico--teal { background: #ccfbf1; color: #0d9488; } +#aap-app .aap-ico--purple { background: #e9d5ff; color: #9333ea; } +#aap-app .aap-ico--lg { width: 42px; height: 42px; flex: 0 0 42px; border-radius: 12px; } +#aap-app .aap-ico--lg svg { width: 21px; height: 21px; } + +/* ─── Botões ────────────────────────────────────────────────────────────── */ + +#aap-app .aap-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + height: auto; + padding: 8px 16px; + border-radius: 8px; + border: 1px solid transparent; + font-size: 13px; + font-weight: 600; + line-height: 1.4; + text-decoration: none; + cursor: pointer; + background: #fff; + color: var(--aap-text); + box-shadow: none; + transition: background .15s ease, border-color .15s ease, color .15s ease; +} + +#aap-app .aap-btn svg { width: 15px; height: 15px; } + +#aap-app .aap-btn-primary, +#aap-app .aap-btn-primary:visited { + background: var(--aap-blue); + border-color: var(--aap-blue); + color: #fff; +} + +#aap-app .aap-btn-primary:hover, +#aap-app .aap-btn-primary:focus { + background: var(--aap-blue-hover); + border-color: var(--aap-blue-hover); + color: #fff; + box-shadow: none; +} + +#aap-app .aap-btn-outline, +#aap-app .aap-btn-outline:visited { + background: #fff; + border-color: #cbd5e1; + color: var(--aap-blue-dark); +} + +#aap-app .aap-btn-outline:hover, +#aap-app .aap-btn-outline:focus { + background: var(--aap-blue-soft); + border-color: #93c5fd; + color: var(--aap-blue-dark); + box-shadow: none; +} + +#aap-app .aap-btn-danger-outline, +#aap-app .aap-btn-danger-outline:visited { + background: #fff; + border-color: #fca5a5; + color: var(--aap-red); +} + +#aap-app .aap-btn-danger-outline:hover, +#aap-app .aap-btn-danger-outline:focus { + background: #fef2f2; + border-color: #f87171; + color: #b91c1c; + box-shadow: none; +} + +#aap-app .aap-btn:disabled, +#aap-app .aap-btn[disabled] { + opacity: .6; + cursor: not-allowed; +} + +/* ─── Masthead da aba Configurações ─────────────────────────────────────── */ + +#aap-app .aap-settings-head { + display: flex; + align-items: flex-start; + gap: 16px; + margin-bottom: 18px; +} + +#aap-app .aap-settings-head .aap-title { font-size: 24px; } + +/* ─── Botões de categoria (âncoras) ────────────────────────────────────── */ + +/* Área de conteúdo do painel (cabeçalho fica fora dela) */ +#aap-app .aap-panel-body { min-height: 0; } + +#aap-app .aap-panel-body > :first-child { margin-top: 0; } + +#aap-app .aap-cat-links { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0; + background: none; + border: 0; + border-radius: 0; + margin-bottom: 22px; +} + +#aap-app .aap-cat-links a, +#aap-app .aap-cat-links a:visited { + display: inline-flex; + align-items: center; + padding: 8px 15px; + border: 1px solid #d7e2f5; + border-radius: 8px; + background: #f4f8ff; + color: #1e40af; + font-size: 14px; + font-weight: 600; + line-height: 1.4; + text-decoration: none; + transition: background .15s ease, border-color .15s ease, color .15s ease; +} + +#aap-app .aap-cat-links a:hover, +#aap-app .aap-cat-links a:focus { + background: #e8f0ff; + border-color: #a9c5f5; + color: var(--aap-blue); + box-shadow: none; +} + +#aap-app .aap-cat-links a.is-active, +#aap-app .aap-cat-links a.is-active:hover { + background: var(--aap-blue); + border-color: var(--aap-blue); + color: #fff; +} + +#aap-app .aap-cat-links a.is-flash { animation: aap-flash 1.4s ease; } + +@keyframes aap-flash { + 0%, 100% { transform: none; } + 25% { transform: translateY(-1px); box-shadow: 0 4px 10px -4px rgba(37, 99, 235, .5); } +} + +/* ─── Grid de cards ─────────────────────────────────────────────────────── */ + +#aap-app .aap-cards { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +#aap-app .aap-card { + background: #fff; + border: 1px solid var(--aap-border); + border-radius: 10px; + box-shadow: 0 1px 2px rgba(15, 23, 42, .04); + overflow: hidden; + scroll-margin-top: 260px; +} + +#aap-app .aap-card-head { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + padding: 14px 18px; + margin: 0; + background: none; + border: 0; + border-radius: 0; + text-align: left; + color: inherit; + box-shadow: none; + font-family: inherit; +} + +#aap-app .aap-card-head:hover { background: #fbfdff; box-shadow: none; } + +#aap-app .aap-card-head:focus-visible { + outline: 2px solid var(--aap-blue); + outline-offset: -2px; +} + +#aap-app .aap-card-heading { flex: 1; min-width: 0; } + +#aap-app .aap-card-title { + margin: 0; + padding: 0; + font-size: 17px; + font-weight: 700; + color: var(--aap-text); + line-height: 1.3; +} + +#aap-app .aap-card-desc { + margin: 3px 0 0; + font-size: 13.5px; + color: var(--aap-muted); + line-height: 1.45; +} + +#aap-app .aap-card-body { padding: 2px 18px 16px; } + +/* ─── Layout do card (título/descrição em linhas próprias) ──────────────── */ + +#aap-app .aap-card-heading { display: block; } + +#aap-app .aap-card-title { display: block; } + +#aap-app .aap-card-desc { display: block; } + +/* ─── Campos ────────────────────────────────────────────────────────────── */ + +#aap-app .aap-field { + display: grid; + grid-template-columns: 190px minmax(0, 1fr); + gap: 8px 22px; + align-items: start; + padding: 9px 0; +} + +#aap-app .aap-field > * { min-width: 0; } + +#aap-app .aap-field > label, +#aap-app .aap-field > .aap-label { + font-size: 14px; + font-weight: 700; + color: #334155; + padding-top: 8px; + line-height: 1.4; +} + +#aap-app .aap-field-full { grid-template-columns: minmax(0, 1fr); } +#aap-app .aap-field-full > label { padding-top: 0; padding-bottom: 3px; } +#aap-app .aap-field-full .aap-help { margin-top: 5px; } + +#aap-app .aap-field-input { min-width: 0; } + +#aap-app .aap-help { + margin: 6px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--aap-muted); +} + +#aap-app .aap-help:first-child { margin-top: 0; } + +#aap-app .aap-help code { + font-size: 12px; + background: #f1f5f9; + color: #0f172a; + padding: 1px 5px; + border-radius: 4px; + border: 1px solid #e2e8f0; +} + +#aap-app .aap-help a { color: var(--aap-blue); text-decoration: none; font-weight: 500; } +#aap-app .aap-help a:hover { text-decoration: underline; } + +/* Inputs, selects e textareas */ +#aap-app input[type="text"], +#aap-app input[type="password"], +#aap-app input[type="number"], +#aap-app input[type="datetime-local"], +#aap-app select, +#aap-app textarea { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + min-height: 40px; + height: auto; + margin: 0; + padding: 8px 12px; + border: 1px solid #cbd5e1; + border-radius: 7px; + background-color: #fff; + color: var(--aap-text); + font-size: 14.5px; + font-family: inherit; + line-height: 1.45; + box-shadow: inset 0 1px 1px rgba(15, 23, 42, .03); + transition: border-color .15s ease, box-shadow .15s ease; +} + +#aap-app select { + padding-right: 32px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2 4.5 6 8.5 10 4.5' fill='none' stroke='%2364748b' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 11px center; + background-size: 12px; + -webkit-appearance: none; + appearance: none; +} + +#aap-app textarea { resize: vertical; min-height: 76px; line-height: 1.55; } + +#aap-app input[type="text"]:focus, +#aap-app input[type="password"]:focus, +#aap-app input[type="number"]:focus, +#aap-app input[type="datetime-local"]:focus, +#aap-app select:focus, +#aap-app textarea:focus { + border-color: var(--aap-blue); + box-shadow: 0 0 0 3px rgba(37, 99, 235, .15); + outline: none; +} + +#aap-app input[type="number"] { max-width: 110px; } + +#aap-app .aap-inline { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 10px; +} + +#aap-app .aap-inline > .aap-radio, +#aap-app .aap-inline > .aap-check { flex: 0 0 auto; } + +#aap-app .aap-inline .aap-inline-label { + font-size: 12.5px; + font-weight: 500; + color: #475569; +} + +#aap-app .aap-inline input[type="number"] { width: 64px; min-width: 64px; max-width: 64px; flex: 0 0 auto; } +#aap-app .aap-inline input[type="number"].aap-w-70 { width: 70px; min-width: 70px; max-width: 70px; } +#aap-app .aap-inline select { width: auto; min-width: 0; flex: 0 1 auto; } +#aap-app .aap-inline .aap-help { flex: 1 1 100%; } +#aap-app .aap-inline > .aap-btn { flex: 0 0 auto; } + +/* Radios e checkboxes */ +#aap-app .aap-radio, +#aap-app .aap-check { + display: inline-flex; + align-items: center; + gap: 7px; + flex-wrap: nowrap; + font-size: 13px; + font-weight: 400; + color: #334155; + cursor: pointer; + margin: 0; + white-space: nowrap; +} + +#aap-app .aap-radio > span, +#aap-app .aap-check > span { white-space: nowrap; } + +#aap-app input[type="radio"], +#aap-app input[type="checkbox"] { + margin: 0; + width: 16px; + height: 16px; + min-width: 16px; + max-width: 16px; + flex: 0 0 16px; + border: 1px solid #94a3b8; + background: #fff; + box-shadow: none; + -webkit-appearance: none; + appearance: none; + position: relative; + display: inline-block; + vertical-align: middle; + cursor: pointer; +} + +#aap-app input[type="radio"] { border-radius: 50%; } +#aap-app input[type="checkbox"] { border-radius: 4px; } + +/* O WordPress injeta um ::before branco fora de centro no radio marcado, + que aparecia como um "luar". O indicador é desenhado aqui. */ +#aap-app input[type="radio"]::before { content: none; } + +#aap-app input[type="radio"]:checked { + border-color: var(--aap-blue); + background: var(--aap-blue); + box-shadow: none; +} + +#aap-app input[type="radio"]:checked::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + display: block; + width: 6px; + height: 6px; + margin: 0; + border-radius: 50%; + background: #fff; + transform: translate(-50%, -50%); +} + +#aap-app input[type="checkbox"]:checked { + border-color: var(--aap-blue); + background: var(--aap-blue); +} + +#aap-app input[type="checkbox"]:checked::before { + content: ""; + position: absolute; + inset: 0; + margin: 0; + width: auto; + height: auto; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5 10.5 8.5 14 15 6.5' fill='none' stroke='%23fff' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/13px no-repeat; +} + +#aap-app input[type="radio"]:focus-visible, +#aap-app input[type="checkbox"]:focus-visible { + outline: 2px solid var(--aap-blue); + outline-offset: 2px; +} + +#aap-app .aap-check-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px 12px; +} + +#aap-app .aap-check-grid .aap-check { + font-size: 12.5px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +#aap-app .aap-check-grid .aap-check input { flex: 0 0 16px; } + +/* Divisor interno com rótulo */ +#aap-app .aap-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 14px 0 4px; + padding-top: 14px; + border-top: 1px dashed var(--aap-border); + font-size: 10.5px; + font-weight: 700; + letter-spacing: .9px; + text-transform: uppercase; + color: var(--aap-muted-2); +} + +#aap-app .aap-field:first-child + .aap-divider { margin-top: 0; border-top: none; padding-top: 0; } +#aap-app .aap-card-body > .aap-divider:first-child { display: none; } + +/* Aviso flutuante de configurações salvas */ +#aap-app .aap-toast { + position: fixed; + right: 28px; + bottom: calc(var(--aap-footer-h, 48px) + 16px); + z-index: 101; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 11px 16px; + background: var(--aap-green-soft); + border: 1px solid var(--aap-green-line); + border-radius: 10px; + color: #15803d; + font-size: 13px; + font-weight: 600; + box-shadow: 0 16px 32px -18px rgba(15, 23, 42, .45); + animation: aap-float-in .18s ease; +} + +#aap-app .aap-toast[hidden] { display: none; } +#aap-app .aap-toast svg { width: 16px; height: 16px; flex: 0 0 16px; } + +/* Barra flutuante de alterações não salvas */ +#aap-app .aap-save-float { + position: fixed; + right: 28px; + bottom: calc(var(--aap-footer-h, 48px) + 16px); + z-index: 100; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + background: #fff; + border: 1px solid var(--aap-border); + border-radius: 12px; + box-shadow: 0 18px 38px -18px rgba(15, 23, 42, .45), 0 2px 6px rgba(15, 23, 42, .08); + animation: aap-float-in .18s ease; +} + +#aap-app .aap-save-float[hidden] { display: none; } + +#aap-app .aap-save-float-note { + font-size: 12.5px; + font-weight: 600; + color: var(--aap-muted); + margin-right: 4px; + white-space: nowrap; +} + +@keyframes aap-float-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} + +@media (max-width: 782px) { + #aap-app .aap-save-float, + #aap-app .aap-toast { + left: 12px; + right: 12px; + bottom: 12px; + } + #aap-app .aap-save-float { flex-wrap: wrap; } + #aap-app .aap-save-float-note { flex: 1 1 100%; } + #aap-app .aap-save-float .aap-btn { flex: 1 1 auto; justify-content: center; } +} + +/* ─── Rodapé da aba Ajuda ───────────────────────────────────────────────── */ + +#aap-app .aap-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 22px; + font-size: 12px; + color: var(--aap-muted); +} + +#aap-app .aap-meta kbd { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + padding: 1px 6px; + border: 1px solid var(--aap-border); + border-bottom-width: 2px; + border-radius: 4px; + background: var(--aap-bg); + color: #334155; +} + +/* ─── Execuções ─────────────────────────────────────────────────────────── */ + +#aap-app .aap-actions { display: flex; gap: 10px; margin-left: auto; flex: 0 0 auto; } + +#aap-app .aap-exec-status { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 10px; + background: var(--aap-green-soft); + border: 1px solid var(--aap-green-line); + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 18px; +} + +#aap-app .aap-exec-status > .aap-dot { align-self: center; } + +#aap-app .aap-exec-status .aap-dot { + margin-top: 6px; + background: #16a34a; + box-shadow: 0 0 0 3px rgba(22, 163, 74, .16); +} + +#aap-app .aap-exec-status.is-idle { + background: var(--aap-bg); + border-color: var(--aap-border); +} + +#aap-app .aap-exec-status.is-idle .aap-dot { + background: var(--aap-muted-2); + box-shadow: none; +} + +#aap-app .aap-exec-status.is-aborting { + background: #fffbeb; + border-color: #fde68a; +} + +#aap-app .aap-exec-status.is-aborting .aap-dot { + background: #f59e0b; + box-shadow: 0 0 0 3px rgba(245, 158, 11, .18); +} + +#aap-app .aap-exec-main { flex: 1 1 320px; min-width: 0; } + +#aap-app .aap-exec-title { + font-size: 15px; + font-weight: 700; + color: #14532d; + line-height: 1.3; +} + +#aap-app .aap-exec-status.is-idle .aap-exec-title { color: #334155; } +#aap-app .aap-exec-status.is-aborting .aap-exec-title { color: #92400e; } + +#aap-app .aap-exec-sub { + margin-top: 3px; + font-size: 12.5px; + color: var(--aap-muted); +} + +#aap-app .aap-exec-side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + flex: 0 0 auto; +} + +#aap-app .aap-progress { + position: relative; + height: 8px; + border-radius: 99px; + background: #e2e8f0; + overflow: hidden; + margin-top: 10px; +} + +#aap-app .aap-progress-bar { + position: absolute; + inset: 0 auto 0 0; + width: 0; + max-width: 100%; + border-radius: 99px; + background: var(--aap-blue); + transition: width .4s ease; +} + +#aap-app .aap-progress.is-indeterminate .aap-progress-bar { + width: 35% !important; + animation: aap-indeterminate 1.4s ease-in-out infinite; +} + +@keyframes aap-indeterminate { + 0% { left: -35%; } + 100% { left: 100%; } +} + +#aap-app .aap-progress-foot { + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 6px; + font-size: 11.5px; + color: var(--aap-muted); +} + +#aap-app .aap-exec-status.is-idle .aap-progress, +#aap-app .aap-exec-status.is-idle .aap-progress-foot { display: none; } + +#aap-app .aap-exec-note { + grid-area: auto; + margin: 10px 0 0; + font-size: 11.5px; + color: var(--aap-muted-2); + flex: 1 1 100%; +} + +#aap-app .aap-feedback { font-size: 12.5px; } +#aap-app .aap-feedback.is-error { color: var(--aap-red); } +#aap-app .aap-feedback.is-success { color: var(--aap-green); } +#aap-app .aap-feedback.is-warn { color: var(--aap-amber); } +#aap-app .aap-feedback.is-pending { color: var(--aap-muted); } +#aap-app .aap-feedback:empty { display: none; } + +/* Painel interno (últimos posts, automático, execução) */ +#aap-app .aap-subpanel { + border: 1px solid var(--aap-border); + border-radius: 10px; + margin-bottom: 16px; + overflow: hidden; +} + +#aap-app .aap-subpanel-head { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 12px 16px; + background: #fff; + border: 0; + border-bottom: 1px solid var(--aap-border-soft); + margin: 0; + text-align: left; + color: var(--aap-text); + font-family: inherit; + box-shadow: none; + border-radius: 0; +} + +#aap-app .aap-subpanel-head svg.aap-lead { width: 17px; height: 17px; color: #334155; flex: 0 0 17px; } + +#aap-app .aap-subpanel-title { + flex: 1; + font-size: 14px; + font-weight: 700; +} + +#aap-app .aap-subpanel-body { padding: 16px; } + +/* Tabela de últimos posts */ +#aap-app .aap-table-wrap { overflow-x: auto; } + +#aap-app .aap-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 12.5px; + border: 1px solid var(--aap-border); + border-radius: 8px; + overflow: hidden; +} + +#aap-app .aap-table th { + background: #f8fafc; + border-bottom: 1px solid var(--aap-border); + padding: 9px 12px; + text-align: left; + font-size: 11.5px; + font-weight: 700; + color: #475569; + white-space: nowrap; +} + +#aap-app .aap-table td { + padding: 9px 12px; + border-bottom: 1px solid var(--aap-border-soft); + vertical-align: middle; + color: #334155; +} + +#aap-app .aap-table tbody tr:last-child td { border-bottom: 0; } +#aap-app .aap-table tbody tr:hover td { background: #fbfdff; } + +#aap-app .aap-table .aap-thumb { + width: 52px; + height: 36px; + object-fit: cover; + border-radius: 5px; + display: block; + background: #e2e8f0; +} + +#aap-app .aap-table .aap-thumb-empty { + width: 52px; + height: 36px; + border-radius: 5px; + background: #f1f5f9; + border: 1px dashed #cbd5e1; + display: grid; + place-items: center; + color: #94a3b8; +} + +#aap-app .aap-table .aap-thumb-empty svg { width: 15px; height: 15px; } + +#aap-app .aap-table .aap-col-title { font-weight: 600; color: var(--aap-text); } +#aap-app .aap-table .aap-col-title a { color: var(--aap-text); text-decoration: none; } +#aap-app .aap-table .aap-col-title a:hover { color: var(--aap-blue); } + +#aap-app .aap-table .aap-dot { width: 7px; height: 7px; } + +#aap-app .aap-empty { + text-align: center; + padding: 26px 16px; + color: var(--aap-muted); + font-size: 12.5px; +} + +/* ─── Status do plugin (aba Ajuda) ──────────────────────────────────────── */ + +#aap-app .aap-plugin-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 13.5px; + font-weight: 500; + color: var(--aap-text); +} + +#aap-app .aap-plugin-version { + margin-left: auto; + color: var(--aap-muted); + font-weight: 400; +} + +/* ─── Log ───────────────────────────────────────────────────────────────── */ + +#aap-app .aap-log-shell { + background: #0d1117; + border-radius: 10px; + padding: 8px; + border: 1px solid #1f2937; +} + +#aap-app .aap-log-box { + height: min(62vh, 640px); + min-height: 320px; + overflow-y: auto; + padding: 12px 14px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: 12.5px; + line-height: 1.75; + color: #d4d4d4; + white-space: pre-wrap; + word-break: break-word; + scrollbar-width: thin; + scrollbar-color: #334155 transparent; +} + +#aap-app .aap-log-box::-webkit-scrollbar { width: 10px; } +#aap-app .aap-log-box::-webkit-scrollbar-thumb { background: #334155; border-radius: 99px; } + +#aap-app .aap-log-box .aap-log-line { display: block; } +#aap-app .aap-log-box .aap-log-time { color: #7dd3fc; } +#aap-app .aap-log-box .aap-log-level { font-weight: 700; } +#aap-app .aap-log-box .aap-log-level-info { color: #4ade80; } +#aap-app .aap-log-box .aap-log-level-erro { color: #f87171; } +#aap-app .aap-log-box .aap-log-level-aviso { color: #fbbf24; } +#aap-app .aap-log-box .aap-log-sep { color: #7dd3fc; font-weight: 700; } +#aap-app .aap-log-box .aap-log-empty { color: #64748b; } + +/* Barra superior do log (filtro + status) */ +#aap-app .aap-log-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 2px 6px 10px; +} + +#aap-app .aap-log-toolbar select { + background: #111827; + color: #cbd5e1; + border-color: #334155; +} + +#aap-app .aap-log-status { color: #94a3b8; font-size: 12px; } + +#aap-app .aap-log-more { padding: 10px 6px 2px; text-align: center; } + +#aap-app .aap-log-more .aap-btn { + background: #111827; + border-color: #334155; + color: #cbd5e1; +} + +#aap-app .aap-log-more .aap-btn:hover { + background: #1f2937; + border-color: #475569; + color: #fff; +} + +/* Cada linha do log: hora | nível | mensagem */ +#aap-app .aap-log-box .aap-log-line { + display: flex; + align-items: baseline; + gap: 10px; + padding: 2px 0; + border-bottom: 1px solid rgba(148, 163, 184, .07); +} + +#aap-app .aap-log-box .aap-log-time { flex: 0 0 auto; } +#aap-app .aap-log-box .aap-log-level { flex: 0 0 54px; } +#aap-app .aap-log-box .aap-log-msg { flex: 1 1 auto; } + +/* Campos por provedor de IA */ +#aap-app .aap-provider-block[hidden] { display: none; } + +#aap-app .aap-log-foot { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + padding: 10px 6px 4px; + margin-top: 6px; + position: relative; +} + +#aap-app .aap-log-foot::before { + content: ""; + position: absolute; + top: 0; + left: 6px; + right: 6px; + height: 1px; + background: #1f2937; +} + +#aap-app .aap-log-foot .aap-check { color: #cbd5e1; font-size: 12.5px; } +#aap-app .aap-log-foot .aap-hint-note { color: #94a3b8; } +#aap-app .aap-log-foot .aap-hint-note .aap-dot { box-shadow: 0 0 0 3px rgba(34, 197, 94, .18); } +#aap-app .aap-log-foot .aap-switch-track { background: #334155; } +#aap-app .aap-log-foot .aap-switch input:checked + .aap-switch-track { background: var(--aap-blue); } + +#aap-app .aap-log-foot .aap-log-status { + margin-left: auto; + font-size: 12px; + color: #64748b; +} + +/* Switch (usado na aba de log: atualização automática) */ +#aap-app .aap-switch { position: relative; display: inline-flex; } + +#aap-app .aap-switch input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; + z-index: 2; +} + +#aap-app .aap-switch-track { + width: 40px; + height: 22px; + border-radius: 99px; + background: #cbd5e1; + transition: background .15s ease; + position: relative; + pointer-events: none; +} + +#aap-app .aap-switch-track::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(15, 23, 42, .25); + transition: transform .15s ease; +} + +#aap-app .aap-switch input:checked + .aap-switch-track { background: var(--aap-blue); } +#aap-app .aap-switch input:checked + .aap-switch-track::after { transform: translateX(18px); } +#aap-app .aap-switch input:focus-visible + .aap-switch-track { box-shadow: 0 0 0 3px rgba(37, 99, 235, .3); } +#aap-app .aap-switch input:disabled + .aap-switch-track { opacity: .5; } + +#aap-app .aap-hint-note { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--aap-muted); +} + +#aap-app .aap-hint-note svg { width: 14px; height: 14px; color: #22c55e; } + +/* ─── Responsivo ────────────────────────────────────────────────────────── */ + +/* ─── Layout de aplicação (desktop) ─────────────────────────────────────── */ +/* O cabeçalho fica fora da área de rolagem e o conteúdo rola dentro do painel, + assim nada é cortado por baixo do cabeçalho. */ + +@media screen and (min-width: 783px) { + body.toplevel_page_automatic-ai-posts #wpbody-content { + display: flex; + flex-direction: column; + float: none; + height: calc(100vh - 32px - var(--aap-footer-h, 48px)); + min-height: 0; + padding: 0; + overflow: hidden; + box-sizing: border-box; + } + + body.toplevel_page_automatic-ai-posts #wpbody-content > .wrap { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + margin: 0; + padding: 0; + } + + #aap-app { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + } + + #aap-app .aap-shell { + flex: 1 1 auto; + min-height: 0; + align-items: stretch; + } + + #aap-app .aap-main { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + } + + #aap-app .aap-panel { + flex: 1 1 auto; + min-height: 0; + padding: 0; + display: flex; + flex-direction: column; + } + + #aap-app .aap-panel-body { + flex: 1 1 auto; + min-height: 0; + padding: 16px 20px 22px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + } + + /* Cabeçalho: item fixo no topo do painel, com divisa clara */ + #aap-app .aap-sticky-head { + position: static; + flex: 0 0 auto; + margin: 0; + padding: 18px 20px 6px; + box-shadow: 0 6px 12px -8px rgba(15, 23, 42, .22); + z-index: 2; + } + + #aap-app .aap-sticky-head::after { opacity: 1; } + + /* A sidebar preenche a coluna (a página não rola) */ + #aap-app .aap-sidebar-inner { + position: static !important; + top: auto !important; + left: auto !important; + width: auto !important; + height: 100% !important; + } +} + +@media (max-width: 1280px) { + #aap-app .aap-check-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} + +@media (max-width: 1100px) { + #aap-app .aap-shell { grid-template-columns: 200px minmax(0, 1fr); } + #aap-app .aap-cards { grid-template-columns: minmax(0, 1fr); } + #aap-app .aap-check-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } +} + +@media (max-width: 782px) { + #aap-app { margin: 0; } + #aap-app .aap-shell { grid-template-columns: minmax(0, 1fr); } + #aap-app .aap-sidebar { + border-radius: 0; + } + #aap-app .aap-sidebar-inner { + position: static; + height: auto; + max-height: none; + padding: 14px; + overflow: visible; + } + #aap-app .aap-brand { padding-bottom: 12px; } + #aap-app .aap-nav { flex-direction: row; flex-wrap: wrap; } + #aap-app .aap-nav-item { flex: 1 1 auto; padding: 9px 12px; font-size: 12.5px; } + #aap-app .aap-panel { + padding: 16px; + border-left: 1px solid var(--aap-border); + border-top: 0; + border-radius: 0; + min-height: 0; + } + #aap-app .aap-sticky-head { margin: -16px -16px 0; padding: 16px 16px 6px; border-radius: 0; } + #aap-app .aap-cat-links { gap: 6px; } + #aap-app .aap-field { grid-template-columns: minmax(0, 1fr); } + #aap-app .aap-field > label, + #aap-app .aap-field > .aap-label { padding-top: 0; } + #aap-app .aap-check-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + #aap-app .aap-actions { width: 100%; margin-left: 0; margin-top: 8px; } + #aap-app .aap-panel-head { flex-wrap: wrap; } +} + +/* ─── Aba Ajuda (contribua / créditos / sobre) ──────────────────────────── */ + +#aap-app .aap-help-stack { + display: flex; + flex-direction: column; + gap: 18px; +} + +#aap-app .aap-help-card { + background: #fff; + border: 1px solid var(--aap-border); + border-radius: 12px; + padding: 20px 22px; + box-shadow: 0 1px 2px rgba(15, 23, 42, .04); +} + +#aap-app .aap-help-card-head { + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 16px; +} + +#aap-app .aap-help-card-text h3 { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--aap-text); + line-height: 1.25; +} + +#aap-app .aap-help-card-text p { + margin: 4px 0 0; + font-size: 13.5px; + color: var(--aap-muted); +} + +#aap-app .aap-help-ico { + display: grid; + place-items: center; + width: 46px; + height: 46px; + flex: 0 0 46px; + border-radius: 50%; + background: #eef2ff; + color: #2563eb; +} + +#aap-app .aap-help-ico svg { width: 22px; height: 22px; } + +#aap-app .aap-help-ico.is-red { background: #fee2e2; color: #dc2626; } +#aap-app .aap-help-ico.is-violet { background: #ede9fe; color: #7c3aed; } +#aap-app .aap-help-ico.is-amber { background: #fef3c7; color: #d97706; } +#aap-app .aap-help-ico.is-blue { background: #dbeafe; color: #2563eb; } +#aap-app .aap-help-ico.sm { width: 38px; height: 38px; flex-basis: 38px; } +#aap-app .aap-help-ico.sm svg { width: 19px; height: 19px; } + +/* Doação */ +#aap-app .aap-donate-box { + display: flex; + align-items: center; + gap: 22px; + padding: 20px; + background: #eef2ff; + border: 1px solid #e0e7ff; + border-radius: 12px; +} + +#aap-app .aap-donate-text { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +#aap-app .aap-donate-head { + display: flex; + align-items: center; + gap: 10px; +} + +#aap-app .aap-donate-text h4 { + margin: 0; + font-size: 17px; + font-weight: 700; + color: var(--aap-text); +} + +#aap-app .aap-donate-text p { + margin: 0; + font-size: 13.5px; + color: #475569; + line-height: 1.55; +} + +#aap-app .aap-pix-btn { margin-top: 6px; } + +#aap-app .aap-donate-qr { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +#aap-app .aap-donate-qr img { + display: block; + width: 150px; + height: 150px; + padding: 8px; + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(15, 23, 42, .08); +} + +#aap-app .aap-donate-qr > span { + font-size: 11.5px; + color: var(--aap-muted); +} + +#aap-app .aap-qr-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + width: 150px; + height: 150px; + padding: 12px; + border: 1px dashed #c7d2fe; + border-radius: 10px; + color: #6366f1; + text-align: center; + font-size: 11.5px; + line-height: 1.4; +} + +#aap-app .aap-qr-empty svg { width: 24px; height: 24px; } + +/* "ou" */ +/* Créditos */ +#aap-app .aap-credit-row { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 0; + border-top: 1px solid var(--aap-border-soft); +} + +#aap-app .aap-credit-row:first-of-type { border-top: 0; padding-top: 4px; } + +#aap-app .aap-credit-avatar { + display: grid; + place-items: center; + width: 48px; + height: 48px; + flex: 0 0 48px; + border-radius: 12px; + background: #eef2ff; + color: #2563eb; +} + +#aap-app .aap-credit-avatar svg { width: 26px; height: 26px; } +#aap-app .aap-credit-avatar.is-blue { background: #dbeafe; color: #1d4ed8; } + +#aap-app .aap-credit-text { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13.5px; + color: var(--aap-muted); +} + +#aap-app .aap-credit-text strong { + color: var(--aap-text); + font-size: 14.5px; +} + +#aap-app .aap-credit-name { font-weight: 600; color: #334155; } + +#aap-app .aap-credit-text a { + color: #2563eb; + text-decoration: none; + font-size: 13px; +} + +#aap-app .aap-credit-text a:hover { text-decoration: underline; } + +/* Sobre */ +#aap-app .aap-about-box { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; + background: #eff6ff; + border: 1px solid #dbeafe; + border-radius: 12px; +} + +#aap-app .aap-about-icon { + display: grid; + place-items: center; + width: 54px; + height: 54px; + flex: 0 0 54px; + border-radius: 50%; + background: #dbeafe; + color: #1d4ed8; +} + +#aap-app .aap-about-icon svg { width: 30px; height: 30px; } + +#aap-app .aap-about-text { flex: 1 1 auto; min-width: 0; } + +#aap-app .aap-about-text h4 { + margin: 0 0 4px; + font-size: 17px; + font-weight: 700; + color: var(--aap-text); +} + +#aap-app .aap-about-text p { + margin: 0; + font-size: 13px; + color: #475569; + line-height: 1.5; +} + +#aap-app .aap-version-pill { + flex: 0 0 auto; + align-self: flex-start; + padding: 6px 12px; + border-radius: 99px; + background: #dbeafe; + color: #1e40af; + font-size: 12.5px; + font-weight: 700; + white-space: nowrap; +} + +#aap-app .aap-about-features { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 24px; + margin: 16px 0 0; + padding: 0; + list-style: none; +} + +#aap-app .aap-about-features li { + display: flex; + align-items: center; + gap: 9px; + font-size: 13.5px; + color: #334155; +} + +#aap-app .aap-check-dot { + display: grid; + place-items: center; + width: 18px; + height: 18px; + flex: 0 0 18px; + border-radius: 50%; + background: #2563eb; + color: #fff; +} + +#aap-app .aap-check-dot svg { width: 11px; height: 11px; stroke-width: 2.6; } + +#aap-app .aap-help-links { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--aap-border-soft); + flex-wrap: wrap; +} + +#aap-app .aap-help-links a { + display: inline-flex; + align-items: center; + gap: 6px; + color: #2563eb; + font-size: 13.5px; + font-weight: 500; + text-decoration: none; +} + +#aap-app .aap-help-links a:hover { text-decoration: underline; } +#aap-app .aap-help-links svg { width: 16px; height: 16px; } + +@media (max-width: 782px) { + #aap-app .aap-donate-box { flex-direction: column; align-items: stretch; } + #aap-app .aap-donate-qr { align-self: center; } + #aap-app .aap-about-features { grid-template-columns: minmax(0, 1fr); } + #aap-app .aap-about-box { flex-wrap: wrap; } + #aap-app .aap-help-links { flex-direction: column; align-items: flex-start; } +} + +#aap-app .aap-pix-key { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 4px; + font-size: 12.5px; + color: #475569; +} + +#aap-app .aap-pix-key code { + padding: 3px 8px; + border-radius: 6px; + background: #fff; + border: 1px solid #dbeafe; + color: #1e40af; + font-size: 12px; + word-break: break-all; +} diff --git a/wp-content/plugins/automatic-ai-posts/admin/admin.js b/wp-content/plugins/automatic-ai-posts/admin/admin.js new file mode 100644 index 0000000..11c2b95 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/admin/admin.js @@ -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
é 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 = $(' + page->help(Lang::t('editorial_desc')); ?> + + + +
+ +
+ + page->help(Lang::t('negative_desc'), 'strong'); ?> +
+
+ +
+ +
+ +
+ + page->help(Lang::t('rewrite_desc')); ?> +
+
+ +
+ +
+ +
+ + page->help(Lang::t('image_instructions_desc')); ?> +
+
+ +
+ +
+ + page->help(Lang::t('image_caption_desc')); ?> +
+
+ + + + + +
+ page->card_head('rss', 'aap-ico--violet', Lang::t('section_feeds'), Lang::t('section_feeds_desc')); ?> +
+
+ +
+ + page->help(Lang::t('feeds_desc')); ?> +
+
+
+ +
+ + page->help(Lang::t('feed_max_desc')); ?> +
+
+
+
+ + +
+ page->card_head('gnews', 'aap-ico--green', Lang::t('section_gnews'), Lang::t('section_gnews_desc')); ?> +
+ +
+ +
+ + + + page->help(Lang::t('key_saved_hint')); ?> + + page->help(Lang::t('gnews_key_desc')); ?> +
+
+ +
+ +
+
+ $label) : + ?> + + +
+ page->help(Lang::t('gnews_cats_desc')); ?> +
+
+ +
+ +
+ + page->help(Lang::t('gnews_max_desc')); ?> +
+
+ +
+
+ + +
+ page->card_head('ai', 'aap-ico--orange', Lang::t('section_ai'), Lang::t('section_ai_desc')); ?> +
+ +
+ +
+ + page->help(Lang::t('provider_desc')); ?> +
+
+ + provider_block('nvidia', Lang::t('field_nvidia_key'), 'aap_nvidia_api_key', Lang::t('nvidia_key_desc')); ?> + provider_block('openai', Lang::t('field_openai_key'), 'aap_openai_api_key', Lang::t('openai_key_desc')); ?> + provider_block('anthropic', Lang::t('field_anthropic_key'), 'aap_anthropic_api_key', Lang::t('anthropic_key_desc')); ?> + provider_block('openrouter', Lang::t('field_openrouter_key'), 'aap_openrouter_api_key', Lang::t('openrouter_key_desc')); ?> + +
> +
+ +
+ + page->help(Lang::t('custom_url_desc')); ?> +
+
+ provider_block('custom', Lang::t('field_custom_key'), 'aap_custom_api_key', Lang::t('custom_key_desc'), false); ?> +
+ +
> +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + page->help(Lang::t('vertex_sa_desc')); ?> +
+
+
+ +
+ +
+ + page->help(Lang::t('model_desc')); ?> +
+
+ +
+ +
+
+ + + + +
+ page->help(Lang::t('ai_limits_desc')); ?> +
+
+ +
+ +
+ + + page->help(Lang::t('ai_test_desc')); ?> +
+
+ +
+
+ + +
+ page->card_head('info', 'aap-ico--teal', Lang::t('section_other'), Lang::t('section_other_desc')); ?> +
+
+ +
+ Automatic AI Posts v +
+
+
+ + +
+
+
+ + +
+ + + + + + + + +
> +
+ +
+ + + page->help(Lang::t('key_saved_hint')); ?> + + page->help($help); ?> +
+
+
+ current_tab(); + ?> +
+

Automatic AI Posts

+
+
+ + + +
+ render(); + break; + case 'log': + (new LogPanel($this))->render(); + break; + case 'ajuda': + (new HelpPanel($this))->render(); + break; + default: + (new RunPanel($this))->render(); + } + ?> +
+ +
+
+
+ '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( + '%s%s', + $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 { + ?> +
+ + + + + +
+ ['href' => [], 'target' => [], 'rel' => []], + 'strong' => [], + 'code' => [], + 'br' => [], + ]; + + $tags = []; + foreach (explode(',', $allowed) as $tag) { + $tag = trim($tag); + if (isset($map[$tag])) { + $tags[$tag] = $map[$tag]; + } + } + + echo '

' . wp_kses($text, $tags) . '

'; + } + + public function panel_head(string $icon, string $title_id, string $title, string $subtitle, string $extra = ''): void { + ?> +
+
+ +
+

+

+
+ +
+
+ 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 $articles + * @param string[] $recent_titles + * @return 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 $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 + */ + 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|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; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/PromptLibrary.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/PromptLibrary.php new file mode 100644 index 0000000..3c67217 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/PromptLibrary.php @@ -0,0 +1,164 @@ + $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 <<,

,
    and . +- 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 << '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), + }; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/AnthropicProvider.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/AnthropicProvider.php new file mode 100644 index 0000000..a987898 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/AnthropicProvider.php @@ -0,0 +1,66 @@ +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; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/CustomProvider.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/CustomProvider.php new file mode 100644 index 0000000..a077bb5 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/CustomProvider.php @@ -0,0 +1,25 @@ +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 */ + 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); + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/OpenAiProvider.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/OpenAiProvider.php new file mode 100644 index 0000000..32bd393 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/OpenAiProvider.php @@ -0,0 +1,12 @@ + home_url('/'), + 'X-Title' => 'Automatic AI Posts', + ]; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/VertexProvider.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/VertexProvider.php new file mode 100644 index 0000000..bf94880 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Ai/Providers/VertexProvider.php @@ -0,0 +1,152 @@ + 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; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Content/ContentModule.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Content/ContentModule.php new file mode 100644 index 0000000..09a80b7 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Content/ContentModule.php @@ -0,0 +1,20 @@ + 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('' . $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); + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Editor/EditorModule.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Editor/EditorModule.php new file mode 100644 index 0000000..38d12bd --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Editor/EditorModule.php @@ -0,0 +1,142 @@ +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); + ?> +

    + +

    + + + 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'), + ]); + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Feeds/FeedsModule.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Feeds/FeedsModule.php new file mode 100644 index 0000000..6b95117 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Feeds/FeedsModule.php @@ -0,0 +1,20 @@ + 'General', + 'world' => 'World', + 'nation' => 'Nation', + 'business' => 'Business', + 'technology' => 'Technology', + 'entertainment' => 'Entertainment', + 'sports' => 'Sports', + 'science' => 'Science', + 'health' => 'Health', + ]; + + /** + * @return array + */ + 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; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Feeds/RssFetcher.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Feeds/RssFetcher.php new file mode 100644 index 0000000..872b8c3 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Feeds/RssFetcher.php @@ -0,0 +1,139 @@ + + */ + 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 at the root instead of inside . + $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 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); + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/I18n/I18nModule.php b/wp-content/plugins/automatic-ai-posts/src/Modules/I18n/I18nModule.php new file mode 100644 index 0000000..6fb7213 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/I18n/I18nModule.php @@ -0,0 +1,20 @@ + */ + public static function ta(string $key): array { + self::load(); + + $value = self::$strings[$key] ?? self::$fallback[$key] ?? []; + + return is_array($value) ? $value : []; + } + + /** @return array */ + public static function all(): array { + self::load(); + return array_merge(self::$fallback, self::$strings); + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Images/DuckDuckGo.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Images/DuckDuckGo.php new file mode 100644 index 0000000..5cc18a6 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Images/DuckDuckGo.php @@ -0,0 +1,111 @@ +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; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Images/ImageService.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Images/ImageService.php new file mode 100644 index 0000000..e5a34b2 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Images/ImageService.php @@ -0,0 +1,63 @@ +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); + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Images/ImagesModule.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Images/ImagesModule.php new file mode 100644 index 0000000..6ff4130 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Images/ImagesModule.php @@ -0,0 +1,20 @@ +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'; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Logs/LogRepository.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Logs/LogRepository.php new file mode 100644 index 0000000..79b747c --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Logs/LogRepository.php @@ -0,0 +1,121 @@ +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, 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()); + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Logs/Logger.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Logs/Logger.php new file mode 100644 index 0000000..01a3ed2 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Logs/Logger.php @@ -0,0 +1,36 @@ +getMessage()); + } + } + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Logs/LogsModule.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Logs/LogsModule.php new file mode 100644 index 0000000..94673c1 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Logs/LogsModule.php @@ -0,0 +1,26 @@ + 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 */ + 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 $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 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; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/PipelineModule.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/PipelineModule.php new file mode 100644 index 0000000..b78cb8c --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/PipelineModule.php @@ -0,0 +1,102 @@ +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'; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/RunState.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/RunState.php new file mode 100644 index 0000000..8cd78b5 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/RunState.php @@ -0,0 +1,60 @@ + 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; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/TopicFilter.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/TopicFilter.php new file mode 100644 index 0000000..a2b4931 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Pipeline/TopicFilter.php @@ -0,0 +1,98 @@ + $articles + * @param string[] $terms + * @return 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) + )); + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Posts/PostCreator.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Posts/PostCreator.php new file mode 100644 index 0000000..0354e43 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Posts/PostCreator.php @@ -0,0 +1,71 @@ +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; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Posts/PostsModule.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Posts/PostsModule.php new file mode 100644 index 0000000..a31d634 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Posts/PostsModule.php @@ -0,0 +1,20 @@ +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; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Modules/Schedule/Scheduler.php b/wp-content/plugins/automatic-ai-posts/src/Modules/Schedule/Scheduler.php new file mode 100644 index 0000000..19ab5f4 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Modules/Schedule/Scheduler.php @@ -0,0 +1,111 @@ + $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); + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Support/Icons.php b/wp-content/plugins/automatic-ai-posts/src/Support/Icons.php new file mode 100644 index 0000000..6a48247 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Support/Icons.php @@ -0,0 +1,69 @@ +' . $paths . ''; + } + + public static function logo(string $class = ''): string { + $class_attr = $class !== '' ? ' class="' . esc_attr($class) . '"' : ''; + return ''; + } + + private static function paths(): array { + return [ + 'settings' => '', + 'play' => '', + 'list' => '', + 'help' => '', + 'chevron-down' => '', + 'caret' => '', + 'rss' => '', + 'gnews' => '', + 'ai' => '', + 'info' => '', + 'check' => '', + 'trash' => '', + 'clock' => '', + 'image' => '', + 'save' => '', + 'refresh' => '', + 'square' => '', + 'external' => '', + 'heart' => '', + 'shield' => '', + 'key' => '', + 'globe' => '', + 'database' => '', + 'star' => '', + 'code' => '', + 'pix' => '', + 'user' => '', + 'book' => '', + 'bolt' => '', + 'mail' => '', + ]; + } +} diff --git a/wp-content/plugins/automatic-ai-posts/src/Support/Languages.php b/wp-content/plugins/automatic-ai-posts/src/Support/Languages.php new file mode 100644 index 0000000..38f6709 --- /dev/null +++ b/wp-content/plugins/automatic-ai-posts/src/Support/Languages.php @@ -0,0 +1,68 @@ + [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 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'; + } +} diff --git a/wp-content/plugins/gustavoo-portfolio-core/includes/class-gso-portfolio-settings.php b/wp-content/plugins/gustavoo-portfolio-core/includes/class-gso-portfolio-settings.php index 67e97c6..dbff846 100644 --- a/wp-content/plugins/gustavoo-portfolio-core/includes/class-gso-portfolio-settings.php +++ b/wp-content/plugins/gustavoo-portfolio-core/includes/class-gso-portfolio-settings.php @@ -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' ), diff --git a/wp-content/plugins/gustavoo-portfolio-core/includes/class-gso-seeder.php b/wp-content/plugins/gustavoo-portfolio-core/includes/class-gso-seeder.php index 2cc110f..c856bff 100644 --- a/wp-content/plugins/gustavoo-portfolio-core/includes/class-gso-seeder.php +++ b/wp-content/plugins/gustavoo-portfolio-core/includes/class-gso-seeder.php @@ -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' ); } /** diff --git a/wp-content/themes/gustavoo-portfolio/header.php b/wp-content/themes/gustavoo-portfolio/header.php index 2cfdfb4..d95d710 100644 --- a/wp-content/themes/gustavoo-portfolio/header.php +++ b/wp-content/themes/gustavoo-portfolio/header.php @@ -28,7 +28,7 @@ $gustavoo_primary_shortcut_attr = is_customize_preview() ? ' data-customize-part
    - + diff --git a/wp-content/themes/gustavoo-portfolio/inc/defaults.php b/wp-content/themes/gustavoo-portfolio/inc/defaults.php index a6b7035..74926f3 100644 --- a/wp-content/themes/gustavoo-portfolio/inc/defaults.php +++ b/wp-content/themes/gustavoo-portfolio/inc/defaults.php @@ -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' ), diff --git a/wp-content/themes/gustavoo-portfolio/inc/template-tags.php b/wp-content/themes/gustavoo-portfolio/inc/template-tags.php index ec66c4b..db8ddfb 100644 --- a/wp-content/themes/gustavoo-portfolio/inc/template-tags.php +++ b/wp-content/themes/gustavoo-portfolio/inc/template-tags.php @@ -82,6 +82,29 @@ function gustavoo_portfolio_the_brand() {