Automatic AI Posts, setup automatizado e seeder de permalinks/avatar
Plugin automatic-ai-posts (v2.0.0) - Pipeline completo: coleta de feeds RSS/Atom, filtro por IA, reescrita, categoria, imagem de capa e agendamento - Página de configuração com teste real do provedor de IA - Editor: botão para refazer imagem de capa e metadados, textos em PT-BR - Ajuda com botão Pix (copia-e-cola + QR) e sem nota de avaliação - Cache-busting por mtime nos assets do admin Tema gustavoo-portfolio - Marca do cabeçalho reduzida a "Gustavo" - Suporte a título próprio da marca Setup (docker/wp-init.sh, compose.yaml) - Plugins de terceiros (Post Views Counter, Rank Math, Site Kit) instalados do WordPress.org em vez de versionados - Falha explícita ao final se algum plugin obrigatório não instalar - WPCLI_UID/WPCLI_GID configuráveis Seeder do tema - Links permanentes "Dia e nome" e avatar "Iniciais" aplicados apenas em instalação nova, com regravação do .htaccess
This commit is contained in:
1 parent
5891467266
commit
0ea3651fb5
73 files changed
+8858
-39
No files matched your search
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
namespace AAP\Core;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* PSR-4 style autoloader: AAP\Foo\Bar -> src/Foo/Bar.php
|
||||
*/
|
||||
final class Autoloader {
|
||||
|
||||
private static string $base = '';
|
||||
|
||||
public static function register(string $base): void {
|
||||
self::$base = rtrim($base, '/') . '/';
|
||||
spl_autoload_register([self::class, 'load']);
|
||||
}
|
||||
|
||||
public static function load(string $class): void {
|
||||
if (strncmp($class, 'AAP\\', 4) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relative = substr($class, 4);
|
||||
$path = self::$base . str_replace('\\', '/', $relative) . '.php';
|
||||
|
||||
if (is_readable($path)) {
|
||||
require_once $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
namespace AAP\Core;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* A module is a self-contained unit of the plugin.
|
||||
*
|
||||
* Lifecycle:
|
||||
* register() — attach hooks that must exist before boot (rarely needed).
|
||||
* boot() — attach runtime hooks; dependencies are already booted here.
|
||||
*/
|
||||
abstract class Module {
|
||||
|
||||
/** Unique module id (used by dependencies()). */
|
||||
abstract public function id(): string;
|
||||
|
||||
/** @return string[] ids of modules that must boot first. */
|
||||
public function dependencies(): array {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Called before any boot(), in dependency order. */
|
||||
public function register(): void {
|
||||
}
|
||||
|
||||
/** Called after every module was registered, in dependency order. */
|
||||
public function boot(): void {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
namespace AAP\Core;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Central place for every option the plugin stores.
|
||||
*
|
||||
* IMPORTANT: existing option names are kept exactly as they were in 1.x so
|
||||
* upgrading never loses configuration.
|
||||
*/
|
||||
final class Options {
|
||||
|
||||
public const DEFAULTS = [
|
||||
// Feeds
|
||||
'aap_feeds' => "https://feeds.bbci.co.uk/news/rss.xml\nhttps://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml",
|
||||
'aap_feed_max_items' => 10,
|
||||
|
||||
// GNews
|
||||
'aap_gnews_api_key' => '',
|
||||
'aap_gnews_categories' => [],
|
||||
'aap_gnews_max' => 10,
|
||||
|
||||
// Generation
|
||||
'aap_min_posts' => 1,
|
||||
'aap_max_posts' => 3,
|
||||
'aap_post_status' => 'draft',
|
||||
'aap_post_language' => 'pt-BR',
|
||||
|
||||
// Editorial instructions
|
||||
'aap_relevance_description' => '',
|
||||
'aap_negative_profile' => '',
|
||||
'aap_rewrite_instructions' => '',
|
||||
|
||||
// AI provider
|
||||
'aap_ai_provider' => 'nvidia',
|
||||
'aap_nvidia_api_key' => '',
|
||||
'aap_openai_api_key' => '',
|
||||
'aap_anthropic_api_key' => '',
|
||||
'aap_openrouter_api_key' => '',
|
||||
'aap_custom_api_key' => '',
|
||||
'aap_custom_base_url' => '',
|
||||
'aap_vertex_project' => '',
|
||||
'aap_vertex_location' => 'us-central1',
|
||||
'aap_vertex_service_account'=> '',
|
||||
'aap_model' => '',
|
||||
'aap_ai_timeout' => 120,
|
||||
'aap_ai_max_retries' => 3,
|
||||
|
||||
// Images
|
||||
'aap_image_instructions' => '',
|
||||
'aap_image_caption' => '',
|
||||
|
||||
// Scheduling
|
||||
'aap_auto_interval_value' => 24,
|
||||
'aap_auto_interval_unit' => 'hours',
|
||||
'aap_auto_enabled' => false,
|
||||
'aap_auto_user_id' => 0,
|
||||
'aap_next_auto_run' => 0,
|
||||
'aap_last_run' => '',
|
||||
|
||||
// App
|
||||
'aap_app_language' => 'pt_BR',
|
||||
];
|
||||
|
||||
public static function get(string $key, $fallback = null) {
|
||||
$default = self::DEFAULTS[$key] ?? $fallback;
|
||||
$value = get_option($key, $default);
|
||||
|
||||
return $value === null ? $default : $value;
|
||||
}
|
||||
|
||||
public static function set(string $key, $value): bool {
|
||||
return update_option($key, $value);
|
||||
}
|
||||
|
||||
public static function bool(string $key): bool {
|
||||
return (bool) self::get($key);
|
||||
}
|
||||
|
||||
public static function int(string $key): int {
|
||||
return (int) self::get($key);
|
||||
}
|
||||
|
||||
public static function string(string $key): string {
|
||||
return (string) self::get($key);
|
||||
}
|
||||
|
||||
/** Creates missing options without touching existing ones. */
|
||||
public static function install_defaults(): void {
|
||||
foreach (self::DEFAULTS as $key => $value) {
|
||||
if (get_option($key, null) === null) {
|
||||
add_option($key, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
namespace AAP\Core;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Resolves the module dependency tree and boots it in order.
|
||||
*/
|
||||
final class Registry {
|
||||
|
||||
/** @var array<string, Module> */
|
||||
private static array $modules = [];
|
||||
|
||||
private static bool $booted = false;
|
||||
|
||||
public static function add(Module $module): void {
|
||||
self::$modules[$module->id()] = $module;
|
||||
}
|
||||
|
||||
public static function get(string $id): ?Module {
|
||||
return self::$modules[$id] ?? null;
|
||||
}
|
||||
|
||||
/** @return array<string, Module> */
|
||||
public static function all(): array {
|
||||
return self::$modules;
|
||||
}
|
||||
|
||||
public static function boot(): void {
|
||||
if (self::$booted) {
|
||||
return;
|
||||
}
|
||||
self::$booted = true;
|
||||
|
||||
$order = self::resolve();
|
||||
|
||||
foreach ($order as $id) {
|
||||
self::$modules[$id]->register();
|
||||
}
|
||||
|
||||
foreach ($order as $id) {
|
||||
self::$modules[$id]->boot();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Topological sort. Missing dependencies are skipped with a notice
|
||||
* (a broken module must not take the whole admin down).
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private static function resolve(): array {
|
||||
$resolved = [];
|
||||
$visiting = [];
|
||||
|
||||
$visit = function (string $id) use (&$visit, &$resolved, &$visiting): void {
|
||||
if (isset($resolved[$id]) || isset($visiting[$id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$module = self::$modules[$id] ?? null;
|
||||
if (!$module) {
|
||||
return;
|
||||
}
|
||||
|
||||
$visiting[$id] = true;
|
||||
|
||||
foreach ($module->dependencies() as $dependency) {
|
||||
if (!isset(self::$modules[$dependency])) {
|
||||
if (function_exists('error_log')) {
|
||||
error_log("[Automatic AI Posts] Module \"{$id}\" requires missing module \"{$dependency}\".");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$visit($dependency);
|
||||
}
|
||||
|
||||
unset($visiting[$id]);
|
||||
$resolved[$id] = true;
|
||||
};
|
||||
|
||||
foreach (array_keys(self::$modules) as $id) {
|
||||
$visit($id);
|
||||
}
|
||||
|
||||
return array_keys($resolved);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Admin;
|
||||
|
||||
use AAP\Core\Module;
|
||||
use AAP\Core\Options;
|
||||
use AAP\Modules\Ai\ProviderFactory;
|
||||
use AAP\Modules\I18n\Lang;
|
||||
use AAP\Modules\Pipeline\RunState;
|
||||
use AAP\Support\Languages;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Wires the admin page, assets, settings registration and AJAX.
|
||||
*/
|
||||
final class AdminModule extends Module {
|
||||
|
||||
private SettingsPage $page;
|
||||
|
||||
public function id(): string {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
public function dependencies(): array {
|
||||
return ['logs', 'i18n', 'ai', 'feeds', 'content', 'images', 'posts', 'pipeline', 'schedule'];
|
||||
}
|
||||
|
||||
public function boot(): void {
|
||||
$this->page = new SettingsPage();
|
||||
|
||||
add_action('admin_menu', [$this, 'menu']);
|
||||
add_action('admin_enqueue_scripts', [$this, 'assets']);
|
||||
add_action('admin_init', [$this, 'register_settings']);
|
||||
|
||||
add_filter('wp_redirect', [$this->page, 'keep_tab_on_redirect']);
|
||||
|
||||
(new Ajax($this->page))->register();
|
||||
|
||||
add_filter('plugin_row_meta', [$this, 'row_meta'], 10, 2);
|
||||
}
|
||||
|
||||
public function row_meta(array $links, string $file): array {
|
||||
if (plugin_basename(AAP_PLUGIN_FILE) !== $file) {
|
||||
return $links;
|
||||
}
|
||||
|
||||
array_unshift($links, '<strong>Automatic AI Posts</strong> v' . AAP_VERSION);
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
public function menu(): void {
|
||||
$svg = (string) file_get_contents(AAP_PLUGIN_DIR . 'admin/icon.svg');
|
||||
|
||||
add_menu_page(
|
||||
'Automatic AI Posts',
|
||||
'Automatic AI Posts',
|
||||
'manage_options',
|
||||
'automatic-ai-posts',
|
||||
[$this->page, 'render'],
|
||||
'data:image/svg+xml;base64,' . base64_encode($svg),
|
||||
80
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache busting for the bundled admin assets.
|
||||
*
|
||||
* Uses AAP_VERSION plus the file mtime so browsers pick up an edited
|
||||
* admin.js/admin.css even when the plugin version string did not change,
|
||||
* which would otherwise leave a stale script cached forever.
|
||||
*/
|
||||
private function asset_version(string $relative): string {
|
||||
$path = AAP_PLUGIN_DIR . $relative;
|
||||
|
||||
return AAP_VERSION . '.' . (file_exists($path) ? (string) filemtime($path) : '0');
|
||||
}
|
||||
|
||||
public function assets(string $hook): void {
|
||||
if ($hook !== 'toplevel_page_automatic-ai-posts') {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style('aap-admin', AAP_PLUGIN_URL . 'admin/admin.css', [], $this->asset_version('admin/admin.css'));
|
||||
wp_enqueue_script('aap-admin', AAP_PLUGIN_URL . 'admin/admin.js', ['jquery'], $this->asset_version('admin/admin.js'), true);
|
||||
|
||||
$started_at = RunState::started_at();
|
||||
$next = (int) get_option('aap_next_auto_run', 0);
|
||||
|
||||
wp_localize_script('aap-admin', 'aapData', [
|
||||
'tab' => $this->page->current_tab(),
|
||||
'ajaxurl' => admin_url('admin-ajax.php'),
|
||||
'options_url' => admin_url('options.php'),
|
||||
'nonce_start' => wp_create_nonce('aap_start_pipeline'),
|
||||
'nonce_status' => wp_create_nonce('aap_check_status'),
|
||||
'nonce_toggle_auto' => wp_create_nonce('aap_toggle_auto'),
|
||||
'nonce_logs' => wp_create_nonce('aap_fetch_logs'),
|
||||
'nonce_abort' => wp_create_nonce('aap_abort_execution'),
|
||||
'processing' => RunState::is_running(),
|
||||
'started_at' => $started_at,
|
||||
'started_label' => $started_at > 0
|
||||
? sprintf(Lang::t('started_in'), wp_date('d/m/Y H:i:s', $started_at))
|
||||
: Lang::t('running_now'),
|
||||
'idle_desc' => Lang::t('idle_desc'),
|
||||
'interval_value' => (string) Options::get('aap_auto_interval_value', 24),
|
||||
'interval_unit' => (string) Options::get('aap_auto_interval_unit', 'hours'),
|
||||
'auto_enabled' => Options::bool('aap_auto_enabled'),
|
||||
'next_run' => $next > 0 ? wp_date('d/m/Y H:i', $next) : '',
|
||||
'log_per_page' => 50,
|
||||
'provider_defaults' => ProviderFactory::DEFAULT_MODELS,
|
||||
's' => [
|
||||
'no_execution' => Lang::t('js_no_execution'),
|
||||
'running' => Lang::t('js_running'),
|
||||
'aborting' => Lang::t('js_aborting'),
|
||||
'run_now' => Lang::t('js_run_now'),
|
||||
'processing' => Lang::t('js_processing'),
|
||||
'error_prefix' => Lang::t('js_error_prefix'),
|
||||
'aborted' => Lang::t('js_aborted'),
|
||||
'wait' => Lang::t('js_wait'),
|
||||
'auto_started' => Lang::t('js_auto_started'),
|
||||
'auto_paused' => Lang::t('js_auto_paused'),
|
||||
'comm_error' => Lang::t('js_comm_error'),
|
||||
'error_start' => Lang::t('js_error_start'),
|
||||
'error_abort' => Lang::t('js_error_abort'),
|
||||
'start_auto' => Lang::t('js_start_auto'),
|
||||
'stop_auto' => Lang::t('js_stop_auto'),
|
||||
'log_empty' => Lang::t('js_log_empty'),
|
||||
'log_updated' => Lang::t('js_log_updated'),
|
||||
'next_run' => Lang::t('js_next_run'),
|
||||
'auto_active' => Lang::t('js_auto_active'),
|
||||
'auto_inactive' => Lang::t('js_auto_inactive'),
|
||||
'schedule_required' => Lang::t('js_schedule_required'),
|
||||
'schedule_past' => Lang::t('js_schedule_past'),
|
||||
'settings_saved' => Lang::t('js_settings_saved'),
|
||||
'preparing' => Lang::t('js_preparing'),
|
||||
'running_feeds' => Lang::t('js_running_feeds'),
|
||||
'sleeping' => Lang::t('js_sleeping'),
|
||||
'status_running' => Lang::t('status_running'),
|
||||
'status_idle' => Lang::t('status_idle'),
|
||||
'log_load_more' => Lang::t('log_load_more'),
|
||||
'log_level_all' => Lang::t('log_level_all'),
|
||||
'processing_feeds' => Lang::t('processing_feeds'),
|
||||
'last_run_prefix' => Lang::t('last_run'),
|
||||
'pix_copied' => Lang::t('donate_copied'),
|
||||
],
|
||||
'ai_test' => [
|
||||
'testing' => Lang::t('ai_test_testing'),
|
||||
'ok' => Lang::t('ai_test_ok'),
|
||||
'error' => Lang::t('js_comm_error'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function register_settings(): void {
|
||||
$text = 'sanitize_text_field';
|
||||
$textarea = 'sanitize_textarea_field';
|
||||
$absint = 'absint';
|
||||
|
||||
$settings = [
|
||||
'aap_feeds' => [$this, 'sanitize_feeds'],
|
||||
'aap_feed_max_items' => $absint,
|
||||
'aap_gnews_api_key' => $this->api_key('aap_gnews_api_key'),
|
||||
'aap_gnews_categories' => [$this, 'sanitize_gnews_categories'],
|
||||
'aap_gnews_max' => $absint,
|
||||
'aap_min_posts' => $absint,
|
||||
'aap_max_posts' => $absint,
|
||||
'aap_post_status' => $this->enum(['draft', 'publish']),
|
||||
'aap_post_language' => $this->enum(array_keys(Languages::POST)),
|
||||
'aap_app_language' => $this->enum(array_keys(Languages::APP)),
|
||||
'aap_relevance_description' => $textarea,
|
||||
'aap_negative_profile' => $textarea,
|
||||
'aap_rewrite_instructions' => $textarea,
|
||||
'aap_image_instructions' => $textarea,
|
||||
'aap_image_caption' => $textarea,
|
||||
'aap_ai_provider' => $this->enum(array_keys(ProviderFactory::LABELS)),
|
||||
'aap_nvidia_api_key' => $this->api_key('aap_nvidia_api_key'),
|
||||
'aap_openai_api_key' => $this->api_key('aap_openai_api_key'),
|
||||
'aap_anthropic_api_key' => $this->api_key('aap_anthropic_api_key'),
|
||||
'aap_openrouter_api_key' => $this->api_key('aap_openrouter_api_key'),
|
||||
'aap_custom_api_key' => $this->api_key('aap_custom_api_key'),
|
||||
'aap_custom_base_url' => 'esc_url_raw',
|
||||
'aap_vertex_project' => $text,
|
||||
'aap_vertex_location' => $text,
|
||||
'aap_vertex_service_account'=> [$this, 'sanitize_json'],
|
||||
'aap_model' => $text,
|
||||
'aap_ai_timeout' => $absint,
|
||||
'aap_ai_max_retries' => $absint,
|
||||
'aap_auto_interval_value' => $absint,
|
||||
'aap_auto_interval_unit' => $this->enum(['minutes', 'hours', 'days']),
|
||||
];
|
||||
|
||||
foreach ($settings as $key => $callback) {
|
||||
register_setting('aap_settings', $key, [
|
||||
'sanitize_callback' => $callback,
|
||||
'show_in_rest' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param mixed $value */
|
||||
public function sanitize_feeds($value): string {
|
||||
$lines = array_filter(array_map('trim', explode("\n", (string) $value)));
|
||||
|
||||
return implode("\n", array_map('esc_url_raw', $lines));
|
||||
}
|
||||
|
||||
/** @param mixed $value */
|
||||
public function sanitize_json($value): string {
|
||||
$value = trim((string) $value);
|
||||
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
json_decode($value);
|
||||
|
||||
return json_last_error() === JSON_ERROR_NONE ? $value : '';
|
||||
}
|
||||
|
||||
/** @param mixed $value */
|
||||
public function sanitize_gnews_categories($value): array {
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$valid = array_keys(\AAP\Modules\Feeds\GnewsFetcher::CATEGORIES);
|
||||
|
||||
return array_values(array_filter($value, static fn($item) => in_array($item, $valid, true)));
|
||||
}
|
||||
|
||||
/**
|
||||
* API keys never travel back to the browser: an empty field keeps the
|
||||
* stored value. A request may still pass "<option>_clear=1" to wipe the
|
||||
* key, which keeps programmatic resets working now that the settings form
|
||||
* no longer shows a "remove saved key" checkbox.
|
||||
*/
|
||||
private function api_key(string $option): callable {
|
||||
return static function ($value) use ($option) {
|
||||
if (isset($_POST[$option . '_clear']) && '1' === (string) wp_unslash($_POST[$option . '_clear'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value === '' ? (string) get_option($option, '') : sanitize_text_field($value);
|
||||
};
|
||||
}
|
||||
|
||||
/** @param string[] $allowed */
|
||||
private function enum(array $allowed): callable {
|
||||
return static function ($value) use ($allowed) {
|
||||
$value = sanitize_text_field((string) $value);
|
||||
|
||||
return in_array($value, $allowed, true) ? $value : ($allowed[0] ?? '');
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Admin;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Modules\Logs\LogRepository;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
use AAP\Modules\Pipeline\PipelineModule;
|
||||
use AAP\Modules\Pipeline\RunState;
|
||||
use AAP\Modules\Schedule\Scheduler;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* AJAX endpoints. Action names are kept from 1.x so the UI keeps working.
|
||||
*/
|
||||
final class Ajax {
|
||||
|
||||
private SettingsPage $page;
|
||||
|
||||
public function __construct(SettingsPage $page) {
|
||||
$this->page = $page;
|
||||
}
|
||||
|
||||
public function register(): void {
|
||||
add_action('wp_ajax_aap_start_pipeline', [$this, 'start_pipeline']);
|
||||
add_action('wp_ajax_aap_check_status', [$this, 'check_status']);
|
||||
add_action('wp_ajax_aap_toggle_auto', [$this, 'toggle_auto']);
|
||||
add_action('wp_ajax_aap_fetch_logs', [$this, 'fetch_logs']);
|
||||
add_action('wp_ajax_aap_abort_execution', [$this, 'abort_execution']);
|
||||
add_action('wp_ajax_aap_test_ai', [$this, 'test_ai']);
|
||||
}
|
||||
|
||||
private function guard(string $nonce): void {
|
||||
check_ajax_referer($nonce, 'nonce');
|
||||
|
||||
if (!current_user_can('manage_options')) {
|
||||
wp_send_json_error(['message' => 'forbidden'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
public function start_pipeline(): void {
|
||||
$this->guard('aap_start_pipeline');
|
||||
|
||||
if (RunState::is_running()) {
|
||||
if (!RunState::is_stale()) {
|
||||
wp_send_json_error(['message' => 'already_running']);
|
||||
}
|
||||
Logger::warn('Stale run detected; clearing state before a new run.', 'pipeline');
|
||||
RunState::stop();
|
||||
}
|
||||
|
||||
$module = \AAP\Core\Registry::get('pipeline');
|
||||
|
||||
if (!$module instanceof PipelineModule) {
|
||||
wp_send_json_error(['message' => 'pipeline_unavailable']);
|
||||
}
|
||||
|
||||
if (!$module->spawn(get_current_user_id())) {
|
||||
wp_send_json_error(['message' => 'cannot_start']);
|
||||
}
|
||||
|
||||
wp_send_json_success();
|
||||
}
|
||||
|
||||
public function check_status(): void {
|
||||
$this->guard('aap_check_status');
|
||||
|
||||
$next = (int) get_option('aap_next_auto_run', 0);
|
||||
|
||||
wp_send_json_success([
|
||||
'processing' => RunState::is_running(),
|
||||
'abort_requested' => (bool) get_transient('aap_abort_requested'),
|
||||
'auto_enabled' => Options::bool('aap_auto_enabled'),
|
||||
'next_run' => $next > 0 ? wp_date('d/m/Y H:i', $next) : '',
|
||||
'last_run' => (string) Options::get('aap_last_run', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggle_auto(): void {
|
||||
$this->guard('aap_toggle_auto');
|
||||
|
||||
$enable = filter_var($_POST['enable'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$mode = sanitize_text_field(wp_unslash($_POST['mode'] ?? 'now'));
|
||||
$scheduled_at = (int) ($_POST['scheduled_at'] ?? 0);
|
||||
|
||||
if (isset($_POST['interval_value'])) {
|
||||
update_option('aap_auto_interval_value', max(1, absint($_POST['interval_value'])));
|
||||
}
|
||||
|
||||
if (isset($_POST['interval_unit'])) {
|
||||
$unit = sanitize_text_field(wp_unslash($_POST['interval_unit']));
|
||||
if (in_array($unit, ['minutes', 'hours', 'days'], true)) {
|
||||
update_option('aap_auto_interval_unit', $unit);
|
||||
}
|
||||
}
|
||||
|
||||
$scheduler = new Scheduler();
|
||||
|
||||
if ($enable) {
|
||||
$scheduler->start($mode, get_current_user_id(), $scheduled_at);
|
||||
} else {
|
||||
$scheduler->stop();
|
||||
}
|
||||
|
||||
$next = (int) get_option('aap_next_auto_run', 0);
|
||||
|
||||
wp_send_json_success([
|
||||
'enabled' => $enable,
|
||||
'next_run' => $next > 0 ? wp_date('d/m/Y H:i', $next) : '',
|
||||
'last_run' => (string) Options::get('aap_last_run', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function fetch_logs(): void {
|
||||
$this->guard('aap_fetch_logs');
|
||||
|
||||
$page = max(1, (int) ($_POST['page'] ?? 1));
|
||||
$per_page = min(100, max(10, (int) ($_POST['per_page'] ?? 50)));
|
||||
$level = sanitize_key(wp_unslash($_POST['level'] ?? ''));
|
||||
|
||||
$result = LogRepository::page($page, $per_page, $level);
|
||||
|
||||
$rows = array_map(static function ($row): array {
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'time' => mysql2date('d/m/Y H:i:s', $row->created_at),
|
||||
'level' => $row->level,
|
||||
'message' => $row->message,
|
||||
'context' => (string) $row->context,
|
||||
];
|
||||
}, $result['rows']);
|
||||
|
||||
wp_send_json_success([
|
||||
'rows' => $rows,
|
||||
'page' => $page,
|
||||
'total' => $result['total'],
|
||||
'has_more' => $result['has_more'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_ai(): void {
|
||||
$this->guard('aap_test_ai');
|
||||
|
||||
$result = (new \AAP\Modules\Ai\AiService())->test();
|
||||
|
||||
if ($result['ok']) {
|
||||
wp_send_json_success($result);
|
||||
}
|
||||
|
||||
wp_send_json_error($result);
|
||||
}
|
||||
|
||||
public function abort_execution(): void { $this->guard('aap_abort_execution');
|
||||
|
||||
$pid = (int) get_option('aap_pipeline_pid', 0);
|
||||
$killed = false;
|
||||
|
||||
if ($pid > 0) {
|
||||
if (function_exists('posix_kill')) {
|
||||
posix_kill($pid, 9);
|
||||
$killed = true;
|
||||
} else {
|
||||
shell_exec('kill -9 ' . $pid . ' 2>/dev/null');
|
||||
$killed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// stop() clears the transient, so the abort flag is set afterwards.
|
||||
RunState::stop();
|
||||
RunState::request_abort();
|
||||
|
||||
Logger::warn('Run aborted by the user' . ($killed ? " (PID {$pid} killed)" : '') . '.', 'pipeline');
|
||||
|
||||
wp_send_json_success(['killed' => $killed, 'pid' => $pid]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Admin\Panels;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Modules\Admin\SettingsPage;
|
||||
use AAP\Modules\I18n\Lang;
|
||||
use AAP\Support\Icons;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Ajuda tab: donation, credits and about — stacked in a single column.
|
||||
*/
|
||||
final class HelpPanel {
|
||||
|
||||
/**
|
||||
* EDITE AQUI — doação via Pix.
|
||||
*
|
||||
* PIX_KEY: chave Pix "copia e cola" (payload EMV do Banco Central).
|
||||
* É este o texto que o botão copia para a área de transferência.
|
||||
* PIX_QR: caminho da imagem do QR code dentro do plugin
|
||||
* (basta salvar o arquivo em admin/pix-qrcode.png).
|
||||
*/
|
||||
private const PIX_KEY = '00020126580014BR.GOV.BCB.PIX0136d39969f0-0cfa-431e-8ad0-64d845c6ef0b5204000053039865802BR5925GUSTAVO OLIVEIRA DA SILVA6009SAO PAULO622605222dNkUW7gsvMExFSFt20n09630467BC';
|
||||
private const PIX_QR = 'admin/pix-qrcode.png';
|
||||
|
||||
private SettingsPage $page;
|
||||
|
||||
public function __construct(SettingsPage $page) {
|
||||
$this->page = $page;
|
||||
}
|
||||
|
||||
public function render(): void {
|
||||
$pix = trim(self::PIX_KEY);
|
||||
$qr_src = AAP_PLUGIN_URL . self::PIX_QR;
|
||||
$has_qr = is_readable(AAP_PLUGIN_DIR . self::PIX_QR);
|
||||
|
||||
?>
|
||||
<section class="aap-panel" id="aap-panel-ajuda" aria-labelledby="aap-h-ajuda">
|
||||
|
||||
<?php $this->page->panel_head('help', 'aap-h-ajuda', Lang::t('help_page_title'), Lang::t('help_page_subtitle')); ?>
|
||||
|
||||
<div class="aap-panel-body">
|
||||
<div class="aap-help-stack">
|
||||
|
||||
<?php /* ── Contribua com o Projeto ── */ ?>
|
||||
<section class="aap-help-card">
|
||||
<div class="aap-help-card-head">
|
||||
<span class="aap-help-ico is-red"><?php echo Icons::svg('heart'); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
|
||||
<div class="aap-help-card-text">
|
||||
<h3><?php echo esc_html(Lang::t('section_donate')); ?></h3>
|
||||
<p><?php echo esc_html(Lang::t('section_donate_desc')); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-donate-box">
|
||||
<div class="aap-donate-text">
|
||||
<div class="aap-donate-head">
|
||||
<span class="aap-help-ico is-violet sm"><?php echo Icons::svg('star'); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
|
||||
<h4><?php echo esc_html(Lang::t('donate_title')); ?></h4>
|
||||
</div>
|
||||
<p><?php echo esc_html(Lang::t('donate_desc')); ?></p>
|
||||
|
||||
<button type="button" class="aap-btn aap-btn-primary aap-pix-btn" id="aap-pix-btn"
|
||||
data-pix="<?php echo esc_attr($pix); ?>" <?php disabled($pix === ''); ?>>
|
||||
<?php echo Icons::svg('pix'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span><?php echo esc_html(Lang::t('donate_btn')); ?></span>
|
||||
</button>
|
||||
<?php if ($pix !== '') : ?>
|
||||
<span class="aap-pix-key">
|
||||
<?php echo esc_html(Lang::t('donate_key_label')); ?>
|
||||
<code><?php echo esc_html($pix); ?></code>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<span id="aap-pix-feedback" class="aap-feedback" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
|
||||
<div class="aap-donate-qr">
|
||||
<?php if ($has_qr) : ?>
|
||||
<img src="<?php echo esc_url($qr_src); ?>"
|
||||
alt="<?php echo esc_attr(Lang::t('donate_btn')); ?>" width="150" height="150" loading="lazy">
|
||||
<span><?php echo esc_html(Lang::t('donate_qr_hint')); ?></span>
|
||||
<?php else : ?>
|
||||
<div class="aap-qr-empty">
|
||||
<?php echo Icons::svg('pix'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span><?php echo esc_html(Lang::t('donate_no_qr')); ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php /* ── Créditos ── */ ?>
|
||||
<section class="aap-help-card">
|
||||
<div class="aap-help-card-head">
|
||||
<span class="aap-help-ico is-amber"><?php echo Icons::svg('star'); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
|
||||
<div class="aap-help-card-text">
|
||||
<h3><?php echo esc_html(Lang::t('section_credits')); ?></h3>
|
||||
<p><?php echo esc_html(Lang::t('section_credits_desc')); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-credit-row">
|
||||
<span class="aap-credit-avatar"><?php echo Icons::logo(); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
|
||||
<div class="aap-credit-text">
|
||||
<strong><?php echo esc_html(Lang::t('credit_developer')); ?></strong>
|
||||
<span class="aap-credit-name">Gustavo Oliveira da Silva</span>
|
||||
<a href="https://gustavoo.me/" target="_blank" rel="noopener">https://gustavoo.me/</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php /* ── Sobre o Plugin ── */ ?>
|
||||
<section class="aap-help-card">
|
||||
<div class="aap-help-card-head">
|
||||
<span class="aap-help-ico is-amber"><?php echo Icons::svg('bolt'); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
|
||||
<div class="aap-help-card-text">
|
||||
<h3><?php echo esc_html(Lang::t('section_about')); ?></h3>
|
||||
<p><?php echo esc_html(Lang::t('section_about_desc')); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-about-box">
|
||||
<span class="aap-about-icon"><?php echo Icons::logo(); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
|
||||
<div class="aap-about-text">
|
||||
<h4>Automatic AI Posts</h4>
|
||||
<p><?php echo esc_html(Lang::t('about_desc')); ?></p>
|
||||
</div>
|
||||
<span class="aap-version-pill"><?php echo esc_html(sprintf(Lang::t('version_label'), AAP_VERSION)); ?></span>
|
||||
</div>
|
||||
|
||||
<ul class="aap-about-features">
|
||||
<?php for ($i = 1; $i <= 6; $i++) : ?>
|
||||
<li>
|
||||
<span class="aap-check-dot"><?php echo Icons::svg('check'); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
|
||||
<?php echo esc_html(Lang::t('about_feature_' . $i)); ?>
|
||||
</li>
|
||||
<?php endfor; ?>
|
||||
</ul>
|
||||
|
||||
<div class="aap-help-links">
|
||||
<a href="https://github.com/gustavoo-me" target="_blank" rel="noopener">
|
||||
<?php echo Icons::svg('code'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span><?php echo esc_html(Lang::t('about_github')); ?></span>
|
||||
<?php echo Icons::svg('external'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Admin\Panels;
|
||||
|
||||
use AAP\Modules\Admin\SettingsPage;
|
||||
use AAP\Modules\I18n\Lang;
|
||||
use AAP\Support\Icons;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Log tab: lazy loaded, newest first. No "clear" action.
|
||||
*/
|
||||
final class LogPanel {
|
||||
|
||||
private SettingsPage $page;
|
||||
|
||||
public function __construct(SettingsPage $page) {
|
||||
$this->page = $page;
|
||||
}
|
||||
|
||||
public function render(): void {
|
||||
$actions = '<div class="aap-actions">'
|
||||
. '<label class="aap-check"><input type="checkbox" id="aap-log-live" class="aap-pref-live" checked>'
|
||||
. esc_html(Lang::t('log_live')) . '</label>'
|
||||
. '</div>';
|
||||
?>
|
||||
<section class="aap-panel" id="aap-panel-log" aria-labelledby="aap-h-log">
|
||||
|
||||
<?php $this->page->panel_head('list', 'aap-h-log', Lang::t('log_title'), Lang::t('log_panel_subtitle'), $actions); ?>
|
||||
|
||||
<div class="aap-panel-body">
|
||||
|
||||
<div class="aap-log-shell">
|
||||
<div class="aap-log-toolbar">
|
||||
<select id="rap-log-level" aria-label="<?php echo esc_attr(Lang::t('log_level')); ?>">
|
||||
<option value=""><?php echo esc_html(Lang::t('log_level_all')); ?></option>
|
||||
<option value="info">INFO</option>
|
||||
<option value="warn">WARN</option>
|
||||
<option value="error">ERROR</option>
|
||||
</select>
|
||||
<span id="rap-log-status" class="aap-log-status"><?php echo esc_html(Lang::t('log_loading')); ?></span>
|
||||
</div>
|
||||
|
||||
<div id="rap-log-box" class="aap-log-box" role="log" aria-live="off" aria-label="<?php echo esc_attr(Lang::t('log_title')); ?>"></div>
|
||||
|
||||
<div class="aap-log-more">
|
||||
<button type="button" id="rap-log-more" class="aap-btn aap-btn-outline" hidden>
|
||||
<?php echo Icons::svg('list'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span><?php echo esc_html(Lang::t('log_load_more')); ?></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Admin\Panels;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Modules\Admin\SettingsPage;
|
||||
use AAP\Modules\I18n\Lang;
|
||||
use AAP\Modules\Pipeline\RunState;
|
||||
use AAP\Modules\Schedule\Scheduler;
|
||||
use AAP\Support\Icons;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Execuções tab.
|
||||
*/
|
||||
final class RunPanel {
|
||||
|
||||
private SettingsPage $page;
|
||||
|
||||
public function __construct(SettingsPage $page) {
|
||||
$this->page = $page;
|
||||
}
|
||||
|
||||
public function render(): void {
|
||||
$processing = RunState::is_running();
|
||||
$started_at = RunState::started_at();
|
||||
$started_f = $started_at > 0 ? wp_date('d/m/Y H:i:s', $started_at) : '';
|
||||
$last_run = (string) Options::get('aap_last_run', '');
|
||||
|
||||
$auto_enabled = Options::bool('aap_auto_enabled');
|
||||
$next = (int) get_option('aap_next_auto_run', 0);
|
||||
$next_f = $next > 0 ? wp_date('d/m/Y H:i', $next) : '';
|
||||
|
||||
$status_title = $processing ? Lang::t('running_now') : Lang::t('no_execution');
|
||||
$status_sub = $processing && $started_f
|
||||
? sprintf(Lang::t('started_in'), $started_f)
|
||||
: Lang::t('idle_desc');
|
||||
|
||||
?>
|
||||
<section class="aap-panel" id="aap-panel-execucoes" aria-labelledby="aap-h-execucoes">
|
||||
|
||||
<?php $this->page->panel_head('play', 'aap-h-execucoes', Lang::t('tab_execucoes'), Lang::t('exec_subtitle')); ?>
|
||||
|
||||
<div class="aap-panel-body">
|
||||
|
||||
<div class="aap-subpanel" id="rap-run-panel">
|
||||
<div class="aap-subpanel-head">
|
||||
<?php echo Icons::svg('play', 'aap-lead'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span class="aap-subpanel-title"><?php echo esc_html(Lang::t('section_run_now')); ?></span>
|
||||
</div>
|
||||
<div class="aap-subpanel-body">
|
||||
|
||||
<div class="aap-exec-status<?php echo $processing ? '' : ' is-idle'; ?>" id="rap-exec-section">
|
||||
<span class="aap-dot"></span>
|
||||
<div class="aap-exec-main">
|
||||
<div class="aap-exec-title" id="rap-exec-text"><?php echo esc_html($status_title); ?></div>
|
||||
<div class="aap-exec-sub" id="rap-exec-sub"><?php echo esc_html($status_sub); ?></div>
|
||||
<div class="aap-progress<?php echo $processing ? ' is-indeterminate' : ''; ?>" id="rap-progress">
|
||||
<div class="aap-progress-bar"></div>
|
||||
</div>
|
||||
<div class="aap-progress-foot">
|
||||
<span id="rap-run-status" class="aap-feedback" role="status" aria-live="polite"><?php echo esc_html(Lang::t('processing_feeds')); ?></span>
|
||||
<span id="rap-last-run"><?php echo esc_html(Lang::t('last_run')); ?> <strong><?php echo $last_run !== '' ? esc_html($last_run) : esc_html(Lang::t('never')); ?></strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aap-exec-side">
|
||||
<button type="button" id="rap-run-now" class="aap-btn aap-btn-primary" <?php disabled($processing); ?>>
|
||||
<?php echo $processing ? Icons::svg('refresh') : Icons::svg('play'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span><?php echo esc_html($processing ? Lang::t('running_btn') : Lang::t('js_run_now')); ?></span>
|
||||
</button>
|
||||
<button type="button" id="rap-abort-btn" class="aap-btn aap-btn-danger-outline" style="<?php echo $processing ? '' : 'display:none;'; ?>">
|
||||
<?php echo Icons::svg('square'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span><?php echo esc_html(Lang::t('abort_btn')); ?></span>
|
||||
</button>
|
||||
<div id="rap-abort-status" class="aap-feedback" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<p class="aap-exec-note"><?php echo esc_html(Lang::t('exec_note')); ?></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-subpanel" id="rap-auto-panel">
|
||||
<div class="aap-subpanel-head">
|
||||
<?php echo Icons::svg('clock', 'aap-lead'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span class="aap-subpanel-title"><?php echo esc_html(Lang::t('card_auto')); ?></span>
|
||||
</div>
|
||||
<div class="aap-subpanel-body">
|
||||
|
||||
<div class="aap-field">
|
||||
<span class="aap-label"><?php echo esc_html(Lang::t('auto_status_label')); ?></span>
|
||||
<div class="aap-field-input">
|
||||
<div class="aap-inline">
|
||||
<span id="rap-auto-badge" class="aap-radio" style="cursor:default;font-weight:600;">
|
||||
<span id="rap-auto-badge-dot" class="aap-dot<?php echo $auto_enabled ? ' is-on' : ''; ?>"></span>
|
||||
<span id="rap-auto-badge-text"><?php echo esc_html($auto_enabled ? Lang::t('auto_active') : Lang::t('auto_inactive')); ?></span>
|
||||
</span>
|
||||
<span id="rap-next-run" class="aap-help" style="margin:0;<?php echo ($auto_enabled && $next_f !== '') ? '' : 'display:none;'; ?>">
|
||||
<?php echo esc_html(Lang::t('next_run')); ?> <strong><?php echo esc_html($next_f); ?></strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rap-auto-mode-wrap" class="aap-field" style="<?php echo $auto_enabled ? 'display:none;' : ''; ?>">
|
||||
<span class="aap-label"><?php echo esc_html(Lang::t('auto_first_run')); ?></span>
|
||||
<div class="aap-field-input">
|
||||
<div class="aap-inline" style="margin-bottom:8px;">
|
||||
<label class="aap-radio">
|
||||
<input type="radio" name="aap_auto_mode" value="now" id="rap-mode-now" checked>
|
||||
<?php echo esc_html(Lang::t('start_now')); ?>
|
||||
</label>
|
||||
<label class="aap-radio">
|
||||
<input type="radio" name="aap_auto_mode" value="schedule" id="rap-mode-schedule">
|
||||
<?php echo esc_html(Lang::t('set_schedule')); ?>
|
||||
</label>
|
||||
</div>
|
||||
<div id="rap-schedule-wrap" style="display:none;">
|
||||
<input type="datetime-local" id="rap-scheduled-at">
|
||||
<?php $this->page->help(Lang::t('schedule_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-field">
|
||||
<label for="rap-auto-interval"><?php echo esc_html(Lang::t('field_auto_period')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<div class="aap-inline">
|
||||
<input type="number" id="rap-auto-interval" min="1" max="999"
|
||||
value="<?php echo esc_attr(Options::get('aap_auto_interval_value', 24)); ?>">
|
||||
<select id="rap-auto-unit">
|
||||
<?php foreach (Lang::ta('auto_period_units') as $value => $label) : ?>
|
||||
<option value="<?php echo esc_attr($value); ?>" <?php selected(Options::get('aap_auto_interval_unit', 'hours'), $value); ?>>
|
||||
<?php echo esc_html($label); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<span class="aap-help" style="margin:0;"><?php echo esc_html(Lang::t('auto_period_hint')); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-inline" style="margin-top:6px;">
|
||||
<button type="button" id="rap-toggle-auto" class="aap-btn <?php echo $auto_enabled ? 'aap-btn-outline' : 'aap-btn-primary'; ?>">
|
||||
<?php echo esc_html($auto_enabled ? Lang::t('stop_auto_btn') : Lang::t('start_auto_btn')); ?>
|
||||
</button>
|
||||
<span id="rap-auto-status" class="aap-feedback" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Admin\Panels;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Modules\Admin\SettingsPage;
|
||||
use AAP\Modules\Ai\ProviderFactory;
|
||||
use AAP\Modules\Feeds\GnewsFetcher;
|
||||
use AAP\Modules\I18n\Lang;
|
||||
use AAP\Support\Icons;
|
||||
use AAP\Support\Languages;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Configurações tab.
|
||||
*/
|
||||
final class SettingsPanel {
|
||||
|
||||
private SettingsPage $page;
|
||||
|
||||
public function __construct(SettingsPage $page) {
|
||||
$this->page = $page;
|
||||
}
|
||||
|
||||
public function render(): void {
|
||||
$post_status = (string) Options::get('aap_post_status', 'draft');
|
||||
$provider = ProviderFactory::current_id();
|
||||
$gnews_key = (string) Options::get('aap_gnews_api_key', '');
|
||||
?>
|
||||
<section class="aap-panel" id="aap-panel-configuracoes" aria-labelledby="aap-h-configuracoes">
|
||||
|
||||
<div class="aap-sticky-head">
|
||||
<header class="aap-settings-head">
|
||||
<span class="aap-ico aap-ico--blue aap-ico--lg"><?php echo Icons::svg('settings'); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
|
||||
<div class="aap-panel-head-text">
|
||||
<h2 class="aap-title" id="aap-h-configuracoes"><?php echo esc_html(Lang::t('tab_configuracoes')); ?></h2>
|
||||
<p class="aap-subtitle"><?php echo esc_html(Lang::t('config_subtitle')); ?></p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="aap-cat-links" aria-label="<?php echo esc_attr(Lang::t('config_categories')); ?>">
|
||||
<a href="#aap-card-geral" data-cat="aap-card-geral"><?php echo esc_html(Lang::t('section_general')); ?></a>
|
||||
<a href="#aap-card-prompts" data-cat="aap-card-prompts"><?php echo esc_html(Lang::t('section_prompts')); ?></a>
|
||||
<a href="#aap-card-feeds" data-cat="aap-card-feeds"><?php echo esc_html(Lang::t('section_feeds')); ?></a>
|
||||
<a href="#aap-card-gnews" data-cat="aap-card-gnews"><?php echo esc_html(Lang::t('section_gnews')); ?></a>
|
||||
<a href="#aap-card-ai" data-cat="aap-card-ai"><?php echo esc_html(Lang::t('section_ai')); ?></a>
|
||||
<a href="#aap-card-other" data-cat="aap-card-other"><?php echo esc_html(Lang::t('section_other')); ?></a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="aap-panel-body">
|
||||
|
||||
<form method="post" action="options.php" id="aap-settings-form">
|
||||
<?php settings_fields('aap_settings'); ?>
|
||||
|
||||
<input type="hidden" name="aap_auto_interval_value" value="<?php echo esc_attr(Options::get('aap_auto_interval_value', 24)); ?>">
|
||||
<input type="hidden" name="aap_auto_interval_unit" value="<?php echo esc_attr(Options::get('aap_auto_interval_unit', 'hours')); ?>">
|
||||
|
||||
<div class="aap-cards">
|
||||
|
||||
<?php /* ── Geral ── */ ?>
|
||||
<div class="aap-card" id="aap-card-geral">
|
||||
<?php $this->page->card_head('settings', 'aap-ico--blue', Lang::t('section_general'), Lang::t('section_general_desc')); ?>
|
||||
<div class="aap-card-body">
|
||||
|
||||
<div class="aap-field">
|
||||
<span class="aap-label"><?php echo esc_html(Lang::t('field_pub_mode')); ?></span>
|
||||
<div class="aap-field-input">
|
||||
<div class="aap-inline">
|
||||
<label class="aap-radio">
|
||||
<input type="radio" name="aap_post_status" value="draft" <?php checked($post_status, 'draft'); ?>>
|
||||
<?php echo esc_html(Lang::t('pub_mode_draft_label')); ?>
|
||||
</label>
|
||||
<label class="aap-radio">
|
||||
<input type="radio" name="aap_post_status" value="publish" <?php checked($post_status, 'publish'); ?>>
|
||||
<?php echo esc_html(Lang::t('pub_mode_pub_label')); ?>
|
||||
</label>
|
||||
</div>
|
||||
<?php $this->page->help(Lang::t('pub_mode_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-field">
|
||||
<label for="aap_post_language"><?php echo esc_html(Lang::t('field_language')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<select id="aap_post_language" name="aap_post_language">
|
||||
<?php foreach (Languages::post_options() as $code => $label) : ?>
|
||||
<option value="<?php echo esc_attr($code); ?>" <?php selected(Options::get('aap_post_language'), $code); ?>>
|
||||
<?php echo esc_html($label); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php $this->page->help(Lang::t('language_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-field">
|
||||
<label for="aap_app_language"><?php echo esc_html(Lang::t('field_app_language')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<select id="aap_app_language" name="aap_app_language">
|
||||
<?php foreach (Languages::APP as $code => $label) : ?>
|
||||
<option value="<?php echo esc_attr($code); ?>" <?php selected(Options::get('aap_app_language'), $code); ?>>
|
||||
<?php echo esc_html($label); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php $this->page->help(Lang::t('app_language_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-field">
|
||||
<span class="aap-label"><?php echo esc_html(Lang::t('field_posts_per_run')); ?></span>
|
||||
<div class="aap-field-input">
|
||||
<div class="aap-inline">
|
||||
<label class="aap-inline-label" for="aap_min_posts"><?php echo esc_html(Lang::t('min_posts')); ?></label>
|
||||
<input type="number" id="aap_min_posts" name="aap_min_posts" min="1" value="<?php echo esc_attr(Options::get('aap_min_posts', 1)); ?>">
|
||||
<label class="aap-inline-label" for="aap_max_posts"><?php echo esc_html(Lang::t('max_posts')); ?></label>
|
||||
<input type="number" id="aap_max_posts" name="aap_max_posts" min="1" value="<?php echo esc_attr(Options::get('aap_max_posts', 3)); ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ── Prompts e Filtro ── */ ?>
|
||||
<div class="aap-card" id="aap-card-prompts">
|
||||
<?php $this->page->card_head('shield', 'aap-ico--purple', Lang::t('section_prompts'), Lang::t('section_prompts_desc')); ?>
|
||||
<div class="aap-card-body">
|
||||
|
||||
<div class="aap-field aap-field-full">
|
||||
<label for="aap_relevance_description"><?php echo esc_html(Lang::t('field_editorial')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<textarea id="aap_relevance_description" name="aap_relevance_description" rows="4"
|
||||
placeholder="<?php echo esc_attr(Lang::t('editorial_ph')); ?>"><?php echo $this->page->textarea_value('aap_relevance_description'); ?></textarea>
|
||||
<?php $this->page->help(Lang::t('editorial_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-field aap-field-full">
|
||||
<label for="aap_negative_profile"><?php echo esc_html(Lang::t('field_negative')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<textarea id="aap_negative_profile" name="aap_negative_profile" rows="4"
|
||||
placeholder="<?php echo esc_attr(Lang::t('negative_ph')); ?>"><?php echo $this->page->textarea_value('aap_negative_profile'); ?></textarea>
|
||||
<?php $this->page->help(Lang::t('negative_desc'), 'strong'); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-divider"><?php echo esc_html(Lang::t('rewrite_label')); ?></div>
|
||||
|
||||
<div class="aap-field aap-field-full">
|
||||
<label for="aap_rewrite_instructions"><?php echo esc_html(Lang::t('field_rewrite')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<textarea id="aap_rewrite_instructions" name="aap_rewrite_instructions" rows="4"
|
||||
placeholder="<?php echo esc_attr(Lang::t('rewrite_ph')); ?>"><?php echo $this->page->textarea_value('aap_rewrite_instructions'); ?></textarea>
|
||||
<?php $this->page->help(Lang::t('rewrite_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-divider"><?php echo esc_html(Lang::t('image_label')); ?></div>
|
||||
|
||||
<div class="aap-field aap-field-full">
|
||||
<label for="aap_image_instructions"><?php echo esc_html(Lang::t('field_image_instructions')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<textarea id="aap_image_instructions" name="aap_image_instructions" rows="4"
|
||||
placeholder="<?php echo esc_attr(Lang::t('image_instructions_ph')); ?>"><?php echo $this->page->textarea_value('aap_image_instructions'); ?></textarea>
|
||||
<?php $this->page->help(Lang::t('image_instructions_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-field aap-field-full">
|
||||
<label for="aap_image_caption"><?php echo esc_html(Lang::t('field_image_caption')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<textarea id="aap_image_caption" name="aap_image_caption" rows="3"
|
||||
placeholder="<?php echo esc_attr(Lang::t('image_caption_ph')); ?>"><?php echo $this->page->textarea_value('aap_image_caption'); ?></textarea>
|
||||
<?php $this->page->help(Lang::t('image_caption_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ── Feeds RSS ── */ ?>
|
||||
<div class="aap-card" id="aap-card-feeds">
|
||||
<?php $this->page->card_head('rss', 'aap-ico--violet', Lang::t('section_feeds'), Lang::t('section_feeds_desc')); ?>
|
||||
<div class="aap-card-body">
|
||||
<div class="aap-field aap-field-full">
|
||||
<label for="aap_feeds"><?php echo esc_html(Lang::t('field_feeds')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<textarea id="aap_feeds" name="aap_feeds" rows="5" placeholder="https://exemplo.com/rss.xml"><?php echo esc_textarea((string) Options::get('aap_feeds', '')); ?></textarea>
|
||||
<?php $this->page->help(Lang::t('feeds_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aap-field">
|
||||
<label for="aap_feed_max_items"><?php echo esc_html(Lang::t('field_feed_max')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<input type="number" id="aap_feed_max_items" name="aap_feed_max_items" min="1" max="100" value="<?php echo esc_attr(Options::get('aap_feed_max_items', 10)); ?>">
|
||||
<?php $this->page->help(Lang::t('feed_max_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ── GNews.io ── */ ?>
|
||||
<div class="aap-card" id="aap-card-gnews">
|
||||
<?php $this->page->card_head('gnews', 'aap-ico--green', Lang::t('section_gnews'), Lang::t('section_gnews_desc')); ?>
|
||||
<div class="aap-card-body">
|
||||
|
||||
<div class="aap-field">
|
||||
<label for="aap_gnews_api_key"><?php echo esc_html(Lang::t('field_gnews_key')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<?php $has_gnews = $gnews_key !== ''; ?>
|
||||
<input type="password" id="aap_gnews_api_key" name="aap_gnews_api_key" value=""
|
||||
placeholder="<?php echo $has_gnews ? esc_attr('••••••••••••') : ''; ?>"
|
||||
autocomplete="new-password" spellcheck="false">
|
||||
<?php if ($has_gnews) : ?>
|
||||
<?php $this->page->help(Lang::t('key_saved_hint')); ?>
|
||||
<?php endif; ?>
|
||||
<?php $this->page->help(Lang::t('gnews_key_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-field">
|
||||
<span class="aap-label"><?php echo esc_html(Lang::t('field_gnews_cats')); ?></span>
|
||||
<div class="aap-field-input">
|
||||
<div class="aap-check-grid">
|
||||
<?php
|
||||
$selected = (array) Options::get('aap_gnews_categories', []);
|
||||
$labels = Lang::ta('gnews_categories') ?: GnewsFetcher::CATEGORIES;
|
||||
foreach ($labels as $value => $label) :
|
||||
?>
|
||||
<label class="aap-check">
|
||||
<input type="checkbox" name="aap_gnews_categories[]" value="<?php echo esc_attr($value); ?>" <?php checked(in_array($value, $selected, true)); ?>>
|
||||
<?php echo esc_html($label); ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php $this->page->help(Lang::t('gnews_cats_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-field">
|
||||
<label for="aap_gnews_max"><?php echo esc_html(Lang::t('field_gnews_max')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<input type="number" id="aap_gnews_max" name="aap_gnews_max" min="1" max="100" value="<?php echo esc_attr(Options::get('aap_gnews_max', 10)); ?>">
|
||||
<?php $this->page->help(Lang::t('gnews_max_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ── Inteligência Artificial ── */ ?>
|
||||
<div class="aap-card" id="aap-card-ai">
|
||||
<?php $this->page->card_head('ai', 'aap-ico--orange', Lang::t('section_ai'), Lang::t('section_ai_desc')); ?>
|
||||
<div class="aap-card-body">
|
||||
|
||||
<div class="aap-field">
|
||||
<label for="aap_ai_provider"><?php echo esc_html(Lang::t('field_provider')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<select id="aap_ai_provider" name="aap_ai_provider">
|
||||
<?php foreach (ProviderFactory::LABELS as $id => $label) : ?>
|
||||
<option value="<?php echo esc_attr($id); ?>" <?php selected($provider, $id); ?>><?php echo esc_html($label); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php $this->page->help(Lang::t('provider_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php $this->provider_block('nvidia', Lang::t('field_nvidia_key'), 'aap_nvidia_api_key', Lang::t('nvidia_key_desc')); ?>
|
||||
<?php $this->provider_block('openai', Lang::t('field_openai_key'), 'aap_openai_api_key', Lang::t('openai_key_desc')); ?>
|
||||
<?php $this->provider_block('anthropic', Lang::t('field_anthropic_key'), 'aap_anthropic_api_key', Lang::t('anthropic_key_desc')); ?>
|
||||
<?php $this->provider_block('openrouter', Lang::t('field_openrouter_key'), 'aap_openrouter_api_key', Lang::t('openrouter_key_desc')); ?>
|
||||
|
||||
<div class="aap-provider-block" data-provider="custom" <?php echo $provider === 'custom' ? '' : 'hidden'; ?>>
|
||||
<div class="aap-field">
|
||||
<label for="aap_custom_base_url"><?php echo esc_html(Lang::t('field_custom_url')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<input type="url" id="aap_custom_base_url" name="aap_custom_base_url"
|
||||
value="<?php echo esc_attr(Options::get('aap_custom_base_url', '')); ?>"
|
||||
placeholder="https://api.exemplo.com/v1">
|
||||
<?php $this->page->help(Lang::t('custom_url_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php $this->provider_block('custom', Lang::t('field_custom_key'), 'aap_custom_api_key', Lang::t('custom_key_desc'), false); ?>
|
||||
</div>
|
||||
|
||||
<div class="aap-provider-block" data-provider="vertex" <?php echo $provider === 'vertex' ? '' : 'hidden'; ?>>
|
||||
<div class="aap-field">
|
||||
<label for="aap_vertex_project"><?php echo esc_html(Lang::t('field_vertex_project')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<input type="text" id="aap_vertex_project" name="aap_vertex_project" value="<?php echo esc_attr(Options::get('aap_vertex_project', '')); ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="aap-field">
|
||||
<label for="aap_vertex_location"><?php echo esc_html(Lang::t('field_vertex_location')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<input type="text" id="aap_vertex_location" name="aap_vertex_location" value="<?php echo esc_attr(Options::get('aap_vertex_location', 'us-central1')); ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="aap-field aap-field-full">
|
||||
<label for="aap_vertex_service_account"><?php echo esc_html(Lang::t('field_vertex_sa')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<textarea id="aap_vertex_service_account" name="aap_vertex_service_account" rows="5" spellcheck="false"
|
||||
placeholder='{"type":"service_account", ...}'><?php echo esc_textarea((string) Options::get('aap_vertex_service_account', '')); ?></textarea>
|
||||
<?php $this->page->help(Lang::t('vertex_sa_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-field">
|
||||
<label for="aap_model"><?php echo esc_html(Lang::t('field_model')); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<input type="text" id="aap_model" name="aap_model"
|
||||
value="<?php echo esc_attr(Options::get('aap_model', '')); ?>"
|
||||
data-default-model="<?php echo esc_attr(ProviderFactory::DEFAULT_MODELS[$provider] ?? ''); ?>"
|
||||
placeholder="<?php echo esc_attr(ProviderFactory::DEFAULT_MODELS[$provider] ?? ''); ?>">
|
||||
<?php $this->page->help(Lang::t('model_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-field">
|
||||
<span class="aap-label"><?php echo esc_html(Lang::t('field_ai_limits')); ?></span>
|
||||
<div class="aap-field-input">
|
||||
<div class="aap-inline">
|
||||
<label class="aap-inline-label" for="aap_ai_timeout"><?php echo esc_html(Lang::t('timeout_label')); ?></label>
|
||||
<input type="number" id="aap_ai_timeout" name="aap_ai_timeout" min="10" class="aap-w-70" value="<?php echo esc_attr(Options::get('aap_ai_timeout', 120)); ?>">
|
||||
<label class="aap-inline-label" for="aap_ai_max_retries"><?php echo esc_html(Lang::t('retries_label')); ?></label>
|
||||
<input type="number" id="aap_ai_max_retries" name="aap_ai_max_retries" min="1" max="10" class="aap-w-70" value="<?php echo esc_attr(Options::get('aap_ai_max_retries', 3)); ?>">
|
||||
</div>
|
||||
<?php $this->page->help(Lang::t('ai_limits_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aap-field">
|
||||
<span class="aap-label"><?php echo esc_html(Lang::t('ai_test_label')); ?></span>
|
||||
<div class="aap-field-input">
|
||||
<button type="button" class="aap-btn aap-btn-outline" id="aap-test-ai-btn"
|
||||
data-nonce="<?php echo esc_attr(wp_create_nonce('aap_test_ai')); ?>">
|
||||
<?php echo Icons::svg('bolt'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span><?php echo esc_html(Lang::t('ai_test_btn')); ?></span>
|
||||
</button>
|
||||
<span class="aap-feedback" id="aap-test-ai-feedback" role="status" aria-live="polite"></span>
|
||||
<?php $this->page->help(Lang::t('ai_test_desc')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ── Outras Configurações ── */ ?>
|
||||
<div class="aap-card" id="aap-card-other">
|
||||
<?php $this->page->card_head('info', 'aap-ico--teal', Lang::t('section_other'), Lang::t('section_other_desc')); ?>
|
||||
<div class="aap-card-body">
|
||||
<div class="aap-field">
|
||||
<span class="aap-label"><?php echo esc_html(Lang::t('other_version')); ?></span>
|
||||
<div class="aap-field-input">
|
||||
<strong>Automatic AI Posts v<?php echo esc_html(AAP_VERSION); ?></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aap-field">
|
||||
<span class="aap-label"><?php echo esc_html(Lang::t('other_support')); ?></span>
|
||||
<div class="aap-field-input">
|
||||
<a class="aap-btn aap-btn-outline" href="<?php echo esc_url($this->page->tab_url('ajuda')); ?>">
|
||||
<?php echo Icons::svg('help'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span><?php echo esc_html(Lang::t('tab_ajuda')); ?></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="aap-toast" id="aap-save-toast" hidden>
|
||||
<?php echo Icons::svg('check'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span id="aap-save-feedback" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
|
||||
<div class="aap-save-float" id="aap-save-float" hidden>
|
||||
<span class="aap-save-float-note"><?php echo esc_html(Lang::t('unsaved_changes')); ?></span>
|
||||
<button type="button" class="aap-btn aap-btn-outline" id="aap-discard-all">
|
||||
<?php echo Icons::svg('refresh'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span><?php echo esc_html(Lang::t('discard_changes')); ?></span>
|
||||
</button>
|
||||
<button type="submit" form="aap-settings-form" class="aap-btn aap-btn-primary" id="aap-save-float-btn">
|
||||
<?php echo Icons::svg('check'); // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
<span><?php echo esc_html(Lang::t('save_all')); ?></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
}
|
||||
|
||||
/** Masked API key field. Never prints the stored key. */
|
||||
private function provider_block(string $provider, string $label, string $option, string $help, bool $wrap = true): void {
|
||||
$has_key = (string) Options::get($option, '') !== '';
|
||||
$active = ProviderFactory::current_id() === $provider;
|
||||
?>
|
||||
<div class="aap-provider-block" data-provider="<?php echo esc_attr($provider); ?>" <?php echo $active ? '' : 'hidden'; ?>>
|
||||
<div class="aap-field">
|
||||
<label for="<?php echo esc_attr($option); ?>"><?php echo esc_html($label); ?></label>
|
||||
<div class="aap-field-input">
|
||||
<input type="password" id="<?php echo esc_attr($option); ?>" name="<?php echo esc_attr($option); ?>" value=""
|
||||
placeholder="<?php echo $has_key ? esc_attr('••••••••••••') : ''; ?>"
|
||||
autocomplete="new-password" spellcheck="false">
|
||||
<?php if ($has_key) : ?>
|
||||
<?php $this->page->help(Lang::t('key_saved_hint')); ?>
|
||||
<?php endif; ?>
|
||||
<?php $this->page->help($help); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Admin;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Modules\Admin\Panels\HelpPanel;
|
||||
use AAP\Modules\Admin\Panels\LogPanel;
|
||||
use AAP\Modules\Admin\Panels\RunPanel;
|
||||
use AAP\Modules\Admin\Panels\SettingsPanel;
|
||||
use AAP\Modules\I18n\Lang;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
use AAP\Support\Icons;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Admin page shell: sidebar, tab routing and shared markup helpers.
|
||||
*/
|
||||
final class SettingsPage {
|
||||
|
||||
public const TABS = ['execucoes', 'configuracoes', 'log', 'ajuda'];
|
||||
|
||||
public function render(): void {
|
||||
if (!current_user_can('manage_options')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tab = $this->current_tab();
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1 class="screen-reader-text">Automatic AI Posts</h1>
|
||||
<div id="aap-app" data-tab="<?php echo esc_attr($tab); ?>">
|
||||
<div class="aap-shell">
|
||||
|
||||
<aside class="aap-sidebar">
|
||||
<div class="aap-sidebar-inner">
|
||||
<div class="aap-brand">
|
||||
<span class="aap-brand-icon"><?php echo Icons::logo(); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
|
||||
<span class="aap-brand-title">Automatic<br>AI Posts</span>
|
||||
</div>
|
||||
|
||||
<nav class="aap-nav" aria-label="<?php echo esc_attr(Lang::t('nav_label')); ?>">
|
||||
<?php
|
||||
$this->nav_item('execucoes', 'play', Lang::t('tab_execucoes'), $tab);
|
||||
$this->nav_item('configuracoes', 'settings', Lang::t('tab_configuracoes'), $tab);
|
||||
$this->nav_item('log', 'list', Lang::t('tab_log'), $tab);
|
||||
$this->nav_item('ajuda', 'help', Lang::t('tab_ajuda'), $tab);
|
||||
?>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="aap-main">
|
||||
<?php
|
||||
switch ($tab) {
|
||||
case 'configuracoes':
|
||||
(new SettingsPanel($this))->render();
|
||||
break;
|
||||
case 'log':
|
||||
(new LogPanel($this))->render();
|
||||
break;
|
||||
case 'ajuda':
|
||||
(new HelpPanel($this))->render();
|
||||
break;
|
||||
default:
|
||||
(new RunPanel($this))->render();
|
||||
}
|
||||
?>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function current_tab(): string {
|
||||
$tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'execucoes';
|
||||
|
||||
return in_array($tab, self::TABS, true) ? $tab : 'execucoes';
|
||||
}
|
||||
|
||||
public function tab_url(string $tab, array $extra = []): string {
|
||||
return add_query_arg(
|
||||
array_merge(['page' => 'automatic-ai-posts', 'tab' => $tab], $extra),
|
||||
admin_url('admin.php')
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps the active tab after the settings form is saved. */
|
||||
public function keep_tab_on_redirect($location) {
|
||||
$location = (string) $location;
|
||||
|
||||
if (str_contains($location, 'page=automatic-ai-posts')) {
|
||||
if (!str_contains($location, 'tab=')) {
|
||||
$location = add_query_arg('tab', 'configuracoes', $location);
|
||||
}
|
||||
if (str_contains($location, 'settings-updated=true')) {
|
||||
$location = add_query_arg('aap-saved', '1', $location);
|
||||
Logger::info('Settings saved by user ' . get_current_user_id() . '.', 'admin');
|
||||
}
|
||||
}
|
||||
|
||||
return $location;
|
||||
}
|
||||
|
||||
public function nav_item(string $tab, string $icon, string $label, string $current): void {
|
||||
$active = $tab === $current;
|
||||
|
||||
printf(
|
||||
'<a class="aap-nav-item%s" href="%s"%s><span class="aap-nav-icon">%s</span><span>%s</span></a>',
|
||||
$active ? ' aap-active' : '',
|
||||
esc_url($this->tab_url($tab)),
|
||||
$active ? ' aria-current="page"' : '',
|
||||
Icons::svg($icon), // phpcs:ignore WordPress.Security.EscapeOutput
|
||||
esc_html($label)
|
||||
);
|
||||
}
|
||||
|
||||
public function card_head(string $icon, string $variant, string $title, string $desc): void {
|
||||
?>
|
||||
<div class="aap-card-head">
|
||||
<span class="aap-ico <?php echo esc_attr($variant); ?>"><?php echo Icons::svg($icon); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
|
||||
<span class="aap-card-heading">
|
||||
<span class="aap-card-title"><?php echo esc_html($title); ?></span>
|
||||
<span class="aap-card-desc"><?php echo esc_html($desc); ?></span>
|
||||
</span>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function help(string $text, string $allowed = 'a,strong,code,br'): void {
|
||||
if ($text === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$map = [
|
||||
'a' => ['href' => [], 'target' => [], 'rel' => []],
|
||||
'strong' => [],
|
||||
'code' => [],
|
||||
'br' => [],
|
||||
];
|
||||
|
||||
$tags = [];
|
||||
foreach (explode(',', $allowed) as $tag) {
|
||||
$tag = trim($tag);
|
||||
if (isset($map[$tag])) {
|
||||
$tags[$tag] = $map[$tag];
|
||||
}
|
||||
}
|
||||
|
||||
echo '<p class="aap-help">' . wp_kses($text, $tags) . '</p>';
|
||||
}
|
||||
|
||||
public function panel_head(string $icon, string $title_id, string $title, string $subtitle, string $extra = ''): void {
|
||||
?>
|
||||
<div class="aap-sticky-head">
|
||||
<header class="aap-panel-head">
|
||||
<span class="aap-ico aap-ico--blue aap-ico--lg"><?php echo Icons::svg($icon); // phpcs:ignore WordPress.Security.EscapeOutput ?></span>
|
||||
<div class="aap-panel-head-text">
|
||||
<h2 class="aap-title" id="<?php echo esc_attr($title_id); ?>"><?php echo esc_html($title); ?></h2>
|
||||
<p class="aap-subtitle"><?php echo esc_html($subtitle); ?></p>
|
||||
</div>
|
||||
<?php echo $extra; // phpcs:ignore WordPress.Security.EscapeOutput ?>
|
||||
</header>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function textarea_value(string $key): string {
|
||||
return esc_textarea((string) Options::get($key, ''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Ai;
|
||||
|
||||
use AAP\Core\Module;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* AI providers (NVIDIA Build, OpenAI, Anthropic, OpenRouter, Vertex AI, custom)
|
||||
* and the prompt library.
|
||||
*/
|
||||
final class AiModule extends Module {
|
||||
|
||||
public function id(): string {
|
||||
return 'ai';
|
||||
}
|
||||
|
||||
public function dependencies(): array {
|
||||
return ['logs', 'i18n'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Ai;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
use AAP\Support\Languages;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* High level AI operations used by the pipeline.
|
||||
* Handles retries, parsing and logging so callers stay small.
|
||||
*/
|
||||
final class AiService {
|
||||
|
||||
private ?Provider $provider;
|
||||
private int $retries;
|
||||
|
||||
public function __construct(?Provider $provider = null) {
|
||||
$this->provider = $provider ?? ProviderFactory::make();
|
||||
$this->retries = max(1, Options::int('aap_ai_max_retries'));
|
||||
}
|
||||
|
||||
public function is_ready(): bool {
|
||||
return $this->provider !== null && $this->provider->is_configured();
|
||||
}
|
||||
|
||||
public function provider_label(): string {
|
||||
return $this->provider ? $this->provider->label() : 'none';
|
||||
}
|
||||
|
||||
public function model(): string {
|
||||
return $this->provider ? $this->provider->default_model() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one tiny prompt to confirm the configured provider actually answers.
|
||||
*
|
||||
* Uses a single attempt on purpose: a "test" button should report the real
|
||||
* latency and error instead of hiding a broken key behind retries.
|
||||
*
|
||||
* @return array{ok:bool,provider:string,model:string,reply:string,error:string,ms:int}
|
||||
*/
|
||||
public function test(): array {
|
||||
$result = [
|
||||
'ok' => false,
|
||||
'provider' => $this->provider ? $this->provider->label() : 'none',
|
||||
'model' => $this->model(),
|
||||
'reply' => '',
|
||||
'error' => '',
|
||||
'ms' => 0,
|
||||
];
|
||||
|
||||
if (!$this->provider || !$this->provider->is_configured()) {
|
||||
$result['error'] = __('The provider is not configured yet.', 'automatic-ai-posts');
|
||||
return $result;
|
||||
}
|
||||
|
||||
$started = microtime(true);
|
||||
$reply = $this->provider->complete('Reply with the single word: OK', 16, 0.0);
|
||||
$elapsed = (int) round((microtime(true) - $started) * 1000);
|
||||
|
||||
$result['ms'] = $elapsed;
|
||||
|
||||
if ($reply === null || trim($reply) === '') {
|
||||
$result['error'] = __('The provider did not answer. Check the API key, the model name and the recent log entries.', 'automatic-ai-posts');
|
||||
Logger::error('AI test failed via ' . $result['provider'] . " after {$elapsed}ms.", 'ai');
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['ok'] = true;
|
||||
$result['reply'] = trim(wp_strip_all_tags($reply));
|
||||
Logger::info('AI test succeeded via ' . $result['provider'] . " in {$elapsed}ms.", 'ai');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{title:string,content:string,link:string,source:string}> $articles
|
||||
* @param string[] $recent_titles
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public function select_topics(array $articles, int $min, int $max, array $recent_titles = []): array {
|
||||
if (empty($articles)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$prompt = PromptLibrary::select_topics($articles, $min, $max, $recent_titles);
|
||||
$raw = $this->ask($prompt, 'topic selection', 2048, 0.2);
|
||||
|
||||
if ($raw === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$indexes = $this->parse_number_list($raw, count($articles));
|
||||
|
||||
if (empty($indexes)) {
|
||||
Logger::warn('AI topic selection returned no valid index: "' . substr($raw, 0, 120) . '"', 'ai');
|
||||
return [];
|
||||
}
|
||||
|
||||
$selected = [];
|
||||
foreach ($indexes as $index) {
|
||||
$real = (int) $index - 1;
|
||||
if (isset($articles[$real])) {
|
||||
$selected[] = $articles[$real];
|
||||
}
|
||||
}
|
||||
|
||||
// Never trust the model for the upper bound.
|
||||
return array_slice($selected, 0, $max);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{title:string,content:string,link:string,source:string} $article
|
||||
* @return array{title:string,content:string}|null
|
||||
*/
|
||||
public function rewrite(array $article, string $language): ?array {
|
||||
$prompt = PromptLibrary::rewrite($article, $language);
|
||||
$raw = $this->ask($prompt, 'article rewrite', 8192, 0.8);
|
||||
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sections = $this->parse_sections($raw, ['TITLE', 'CONTENT']);
|
||||
|
||||
if (!empty($sections['TITLE']) && !empty($sections['CONTENT'])) {
|
||||
return ['title' => $sections['TITLE'], 'content' => $sections['CONTENT']];
|
||||
}
|
||||
|
||||
$json = $this->parse_json($raw);
|
||||
if ($json && !empty($json['title']) && !empty($json['content'])) {
|
||||
Logger::warn('Rewrite ignored the delimiter format; recovered from JSON.', 'ai');
|
||||
return ['title' => (string) $json['title'], 'content' => (string) $json['content']];
|
||||
}
|
||||
|
||||
Logger::warn('Rewrite response could not be parsed: "' . substr($raw, 0, 200) . '"', 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $categories id => name
|
||||
*/
|
||||
public function choose_category(string $title, string $content, array $categories, string $language): int {
|
||||
if (empty($categories)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$prompt = PromptLibrary::choose_category($title, $content, $categories, $language);
|
||||
$raw = $this->ask($prompt, 'category choice', 2048, 0.2);
|
||||
|
||||
if ($raw === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Prefer a line that contains only the id (reasoning models add prose).
|
||||
foreach (array_reverse(preg_split('/\r\n|\r|\n/', $raw)) as $line) {
|
||||
$line = trim($line);
|
||||
if (preg_match('/^\d+$/', $line)) {
|
||||
$id = (int) $line;
|
||||
return isset($categories[$id]) ? $id : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: the last valid id mentioned anywhere.
|
||||
preg_match_all('/\b(\d{1,6})\b/', $raw, $m);
|
||||
foreach (array_reverse($m[1] ?? []) as $candidate) {
|
||||
$id = (int) $candidate;
|
||||
if (isset($categories[$id])) {
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the DuckDuckGo query plus the image metadata in a single call.
|
||||
*
|
||||
* @return array{query:string,alt:string,caption:string,description:string}|null
|
||||
*/
|
||||
public function image_plan(string $title, string $content, string $language): ?array {
|
||||
$prompt = PromptLibrary::image_plan($title, $content, $language);
|
||||
$raw = $this->ask($prompt, 'image plan', 4000, 0.7);
|
||||
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sections = $this->parse_sections($raw, ['QUERY', 'ALT', 'CAPTION', 'DESCRIPTION']);
|
||||
|
||||
$query = $this->clean_value($sections['QUERY'] ?? '');
|
||||
|
||||
if ($query === '') {
|
||||
Logger::warn('Image plan without a usable search query: "' . substr($raw, 0, 200) . '"', 'images');
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'query' => $query,
|
||||
'alt' => $this->clean_value($sections['ALT'] ?? ''),
|
||||
'caption' => $this->clean_value($sections['CAPTION'] ?? ''),
|
||||
'description' => $this->clean_value($sections['DESCRIPTION'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** Calls the provider with retries. Returns null when every attempt failed. */
|
||||
private function ask(string $prompt, string $label, int $max_tokens, float $temperature): ?string {
|
||||
if (!$this->provider) {
|
||||
Logger::error('No AI provider configured.', 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->provider->is_configured()) {
|
||||
Logger::error('AI provider "' . $this->provider->label() . '" is not configured.', 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
for ($attempt = 1; $attempt <= $this->retries; $attempt++) {
|
||||
Logger::info("AI request: {$label} (attempt {$attempt}/{$this->retries}) via {$this->provider->label()}", 'ai');
|
||||
|
||||
$raw = $this->provider->complete($prompt, $max_tokens, $temperature);
|
||||
if ($raw !== null) {
|
||||
Logger::info("AI response received for {$label}.", 'ai');
|
||||
return $raw;
|
||||
}
|
||||
|
||||
Logger::warn("AI attempt {$attempt}/{$this->retries} failed for {$label}.", 'ai');
|
||||
}
|
||||
|
||||
Logger::error("AI request failed after {$this->retries} attempt(s): {$label}", 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects placeholder text that reasoning models sometimes echo back
|
||||
* (e.g. "search query here", "alt text here").
|
||||
*/
|
||||
private function clean_value(string $value): string {
|
||||
$value = trim($value, " \t\n\r\"'");
|
||||
$lower = mb_strtolower($value);
|
||||
|
||||
$blocked = [
|
||||
'search query here', 'query here', 'search query', 'query',
|
||||
'alt text here', 'caption here', 'description here',
|
||||
'example', 'exemplo', 'n/a', 'none', 'null', '...',
|
||||
];
|
||||
|
||||
if ($value === '' || mb_strlen($value) < 3 || in_array($lower, $blocked, true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a list of article indexes from the model answer.
|
||||
* Reasoning models often repeat the whole list while thinking, so the
|
||||
* last "numbers only" line wins; otherwise the last numbers are used.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function parse_number_list(string $raw, int $total): array {
|
||||
$candidates = [];
|
||||
|
||||
foreach (array_reverse(preg_split('/\r\n|\r|\n/', $raw)) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line !== '' && preg_match('/^\d+(?:\s*[,\s]\s*\d+)*$/', $line)) {
|
||||
$candidates = preg_split('/[,\s]+/', $line);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($candidates)) {
|
||||
preg_match_all('/\b(\d{1,3})\b/', $raw, $m);
|
||||
$candidates = $m[1] ?? [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
$index = (int) $candidate;
|
||||
if ($index >= 1 && $index <= $total && !in_array($index, $out, true)) {
|
||||
$out[] = $index;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses "===KEY=== value" blocks.
|
||||
*
|
||||
* @param string[] $keys
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function parse_sections(string $raw, array $keys): array {
|
||||
$out = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
// Stops at the next "===" marker (any key) or at the end of the
|
||||
// answer, so truncated responses still yield usable content.
|
||||
$pattern = '/===' . preg_quote($key, '/') . '===\s*(.*?)\s*(?====|\z)/s';
|
||||
|
||||
if (preg_match($pattern, $raw, $m)) {
|
||||
$out[$key] = trim($m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function parse_json(string $raw): ?array {
|
||||
if (preg_match('/\{.*\}/s', $raw, $m)) {
|
||||
$data = json_decode($m[0], true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Ai;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Support\Languages;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Every prompt lives here.
|
||||
*
|
||||
* Rules:
|
||||
* - prompts are written in English (better model adherence);
|
||||
* - the model is told to answer in the configured post language;
|
||||
* - the user's own instructions are injected verbatim, so the panel inputs
|
||||
* are the only place needed to shape the output.
|
||||
*/
|
||||
final class PromptLibrary {
|
||||
|
||||
public static function select_topics(array $articles, int $min, int $max, array $recent_titles): string {
|
||||
$language = Languages::prompt_name(Languages::current_post());
|
||||
$relevance = trim((string) Options::get('aap_relevance_description', ''));
|
||||
$negative = trim((string) Options::get('aap_negative_profile', ''));
|
||||
|
||||
$list = '';
|
||||
foreach ($articles as $i => $article) {
|
||||
$list .= ($i + 1) . '. [' . $article['title'] . '] — ' . $article['source'] . "\n";
|
||||
}
|
||||
|
||||
$total = count($articles);
|
||||
|
||||
$blocks = [];
|
||||
|
||||
if ($relevance !== '') {
|
||||
$blocks[] = "Editorial profile (prioritize articles that match this):\n{$relevance}";
|
||||
}
|
||||
if ($negative !== '') {
|
||||
$blocks[] = "Never select articles that match any of these exclusion criteria:\n{$negative}";
|
||||
}
|
||||
if (!empty($recent_titles)) {
|
||||
$blocks[] = "Already published on this blog (do not repeat these topics):\n- " . implode("\n- ", $recent_titles);
|
||||
}
|
||||
|
||||
$context = $blocks ? implode("\n\n", $blocks) . "\n\n" : '';
|
||||
|
||||
return <<<PROMPT
|
||||
You are an experienced news editor. Below are {$total} articles collected from several sources.
|
||||
Select between {$min} and {$max} articles to publish on the blog.
|
||||
|
||||
Selection rules (apply them strictly):
|
||||
1. The exclusion list below is MANDATORY. If an article's main subject matches ANY excluded topic, it must not be selected — no exceptions, even if it is popular or trending.
|
||||
2. Prefer articles that clearly match the editorial profile.
|
||||
3. Never select two articles about the same topic.
|
||||
4. Never select a topic already covered by the recent posts listed below.
|
||||
5. When in doubt about an article matching an excluded topic, skip it.
|
||||
|
||||
{$context}Reply ONLY with the selected article numbers separated by commas, no extra text.
|
||||
Example: 2,5,8
|
||||
|
||||
Available articles:
|
||||
{$list}
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
public static function rewrite(array $article, string $language_code): string {
|
||||
$language = Languages::prompt_name($language_code);
|
||||
$custom = trim((string) Options::get('aap_rewrite_instructions', ''));
|
||||
|
||||
$custom_block = $custom !== ''
|
||||
? "Additional instructions from the blog owner (follow them):\n{$custom}\n\n"
|
||||
: '';
|
||||
|
||||
return <<<PROMPT
|
||||
You are a journalist and writer. Rewrite the article below as a completely original piece.
|
||||
|
||||
Write the title and the body in {$language}. The output language must be {$language}, regardless of the source language.
|
||||
|
||||
{$custom_block}Rules:
|
||||
- Create an engaging, SEO-friendly title.
|
||||
- Write the body as simple HTML using <p>, <h2>, <ul> and <strong>.
|
||||
- Minimum of 400 words.
|
||||
- Never mention the original source or that the text was rewritten.
|
||||
|
||||
Reply EXACTLY in this format, with nothing before or after:
|
||||
===TITLE===
|
||||
Title here
|
||||
===CONTENT===
|
||||
HTML content here
|
||||
===END===
|
||||
|
||||
Original article:
|
||||
Title: {$article['title']}
|
||||
Source: {$article['source']}
|
||||
Content: {$article['content']}
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
public static function choose_category(string $title, string $content, array $categories, string $language_code): string {
|
||||
$language = Languages::prompt_name($language_code);
|
||||
|
||||
$list = '';
|
||||
foreach ($categories as $id => $name) {
|
||||
$list .= "- ID {$id}: {$name}\n";
|
||||
}
|
||||
|
||||
$excerpt = wp_trim_words(wp_strip_all_tags($content), 60);
|
||||
|
||||
return <<<PROMPT
|
||||
You are a blog editor. Choose the most suitable category for the article below.
|
||||
|
||||
Reply ONLY with the numeric category ID, no extra text.
|
||||
Valid example: 5
|
||||
|
||||
Available categories:
|
||||
{$list}
|
||||
Article title: {$title}
|
||||
Excerpt: {$excerpt}
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* One call produces both the DuckDuckGo query and the image metadata,
|
||||
* so the search and the alt/caption stay consistent with each other.
|
||||
*/
|
||||
public static function image_plan(string $title, string $content, string $language_code): string {
|
||||
$language = Languages::prompt_name($language_code);
|
||||
$extra = trim((string) Options::get('aap_image_instructions', ''));
|
||||
$caption = trim((string) Options::get('aap_image_caption', ''));
|
||||
$excerpt = wp_trim_words(wp_strip_all_tags($content), 70);
|
||||
|
||||
$blocks = [];
|
||||
if ($extra !== '') {
|
||||
$blocks[] = "Image instructions from the blog owner (follow them when choosing the photo and writing the texts):\n{$extra}";
|
||||
}
|
||||
if ($caption !== '') {
|
||||
$blocks[] = "Caption style/template required by the blog owner:\n{$caption}";
|
||||
}
|
||||
$context = $blocks ? implode("\n\n", $blocks) . "\n\n" : '';
|
||||
|
||||
return <<<PROMPT
|
||||
You are an image editor for a news blog.
|
||||
|
||||
Task 1 — search query: write a short DuckDuckGo image search query (2 to 6 words) that would find a real photo representing this article. Use concrete subjects, names, places or objects. Do not use quotes, operators or file extensions.
|
||||
|
||||
Task 2 — metadata: write the alt text, the caption and a description for the cover image.
|
||||
|
||||
Write every text in {$language}. The output language must be {$language}.
|
||||
|
||||
{$context}Reply EXACTLY in this format, with nothing before or after:
|
||||
===QUERY===
|
||||
search query here
|
||||
===ALT===
|
||||
alt text here
|
||||
===CAPTION===
|
||||
caption here
|
||||
===DESCRIPTION===
|
||||
description here
|
||||
===END===
|
||||
|
||||
Article title: {$title}
|
||||
Article excerpt: {$excerpt}
|
||||
PROMPT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Ai;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Contract every AI backend must implement.
|
||||
*/
|
||||
interface Provider {
|
||||
|
||||
/** Machine id (used in options and in the UI). */
|
||||
public function id(): string;
|
||||
|
||||
/** Human readable name. */
|
||||
public function label(): string;
|
||||
|
||||
/** Model used when the user leaves the model field empty. */
|
||||
public function default_model(): string;
|
||||
|
||||
/** Whether the provider has everything it needs to be called. */
|
||||
public function is_configured(): bool;
|
||||
|
||||
/**
|
||||
* Sends a single prompt and returns the raw text answer.
|
||||
* Returns null when the call failed (the caller decides about retries).
|
||||
*/
|
||||
public function complete(string $prompt, int $max_tokens = 4096, float $temperature = 0.7): ?string;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Ai;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Modules\Ai\Providers\AnthropicProvider;
|
||||
use AAP\Modules\Ai\Providers\CustomProvider;
|
||||
use AAP\Modules\Ai\Providers\NvidiaProvider;
|
||||
use AAP\Modules\Ai\Providers\OpenAiProvider;
|
||||
use AAP\Modules\Ai\Providers\OpenRouterProvider;
|
||||
use AAP\Modules\Ai\Providers\VertexProvider;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Builds the provider selected in the settings.
|
||||
*/
|
||||
final class ProviderFactory {
|
||||
|
||||
public const LABELS = [
|
||||
'nvidia' => 'NVIDIA Build',
|
||||
'openai' => 'OpenAI',
|
||||
'anthropic' => 'Anthropic',
|
||||
'openrouter' => 'OpenRouter',
|
||||
'vertex' => 'Google Vertex AI',
|
||||
'custom' => 'Custom (OpenAI compatible)',
|
||||
];
|
||||
|
||||
public const DEFAULT_MODELS = [
|
||||
'nvidia' => 'nvidia/nemotron-3-ultra-550b-a55b',
|
||||
'openai' => 'gpt-4o-mini',
|
||||
'anthropic' => 'claude-3-5-sonnet-latest',
|
||||
'openrouter' => 'openai/gpt-4o-mini',
|
||||
'vertex' => 'gemini-2.0-flash',
|
||||
'custom' => 'gpt-4o-mini',
|
||||
];
|
||||
|
||||
public static function current_id(): string {
|
||||
$id = (string) Options::get('aap_ai_provider', 'nvidia');
|
||||
return isset(self::LABELS[$id]) ? $id : 'nvidia';
|
||||
}
|
||||
|
||||
public static function make(?string $provider_id = null): ?Provider {
|
||||
$id = $provider_id ?? self::current_id();
|
||||
$timeout = max(10, Options::int('aap_ai_timeout'));
|
||||
$model = trim((string) Options::get('aap_model', ''));
|
||||
|
||||
return match ($id) {
|
||||
'openai' => new OpenAiProvider((string) Options::get('aap_openai_api_key', ''), $model, $timeout),
|
||||
'anthropic' => new AnthropicProvider((string) Options::get('aap_anthropic_api_key', ''), $model, $timeout),
|
||||
'openrouter' => new OpenRouterProvider((string) Options::get('aap_openrouter_api_key', ''), $model, $timeout),
|
||||
'vertex' => new VertexProvider(
|
||||
(string) Options::get('aap_vertex_project', ''),
|
||||
(string) Options::get('aap_vertex_location', 'us-central1'),
|
||||
(string) Options::get('aap_vertex_service_account', ''),
|
||||
$model,
|
||||
$timeout
|
||||
),
|
||||
'custom' => new CustomProvider((string) Options::get('aap_custom_api_key', ''), $model, $timeout),
|
||||
default => new NvidiaProvider((string) Options::get('aap_nvidia_api_key', ''), $model, $timeout),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Ai\Providers;
|
||||
|
||||
use AAP\Modules\Ai\Provider;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class AnthropicProvider implements Provider {
|
||||
|
||||
private string $api_key;
|
||||
private string $model;
|
||||
private int $timeout;
|
||||
|
||||
public function __construct(string $api_key, string $model, int $timeout) {
|
||||
$this->api_key = trim($api_key);
|
||||
$this->model = trim($model) !== '' ? trim($model) : $this->default_model();
|
||||
$this->timeout = max(10, $timeout);
|
||||
}
|
||||
|
||||
public function id(): string { return 'anthropic'; }
|
||||
public function label(): string { return 'Anthropic'; }
|
||||
public function default_model(): string { return 'claude-3-5-sonnet-latest'; }
|
||||
public function is_configured(): bool { return $this->api_key !== ''; }
|
||||
|
||||
public function complete(string $prompt, int $max_tokens = 4096, float $temperature = 0.7): ?string {
|
||||
if (!$this->is_configured()) {
|
||||
Logger::error('Anthropic is not configured (missing API key).', 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = wp_remote_post('https://api.anthropic.com/v1/messages', [
|
||||
'timeout' => $this->timeout,
|
||||
'headers' => [
|
||||
'x-api-key' => $this->api_key,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'body' => wp_json_encode([
|
||||
'model' => $this->model,
|
||||
'max_tokens' => $max_tokens,
|
||||
'temperature' => $temperature,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
]),
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
Logger::warn('Anthropic request failed: ' . $response->get_error_message(), 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = (int) wp_remote_retrieve_response_code($response);
|
||||
$body = (string) wp_remote_retrieve_body($response);
|
||||
|
||||
if ($code < 200 || $code >= 300) {
|
||||
Logger::warn('Anthropic HTTP ' . $code . ': ' . substr($body, 0, 300), 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($body, true);
|
||||
$content = $data['content'][0]['text'] ?? null;
|
||||
|
||||
return is_string($content) && trim($content) !== '' ? trim($content) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Ai\Providers;
|
||||
|
||||
use AAP\Core\Options;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Any endpoint that implements the OpenAI /chat/completions contract.
|
||||
*/
|
||||
final class CustomProvider extends OpenAiCompatible {
|
||||
|
||||
public function id(): string { return 'custom'; }
|
||||
public function label(): string { return 'Custom (OpenAI compatible)'; }
|
||||
public function default_model(): string { return 'gpt-4o-mini'; }
|
||||
|
||||
protected function base_url(): string {
|
||||
$base = trim((string) Options::get('aap_custom_base_url', ''));
|
||||
return $base !== '' ? $base : 'https://api.openai.com/v1';
|
||||
}
|
||||
|
||||
public function is_configured(): bool {
|
||||
return parent::is_configured() && trim((string) Options::get('aap_custom_base_url', '')) !== '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Ai\Providers;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class NvidiaProvider extends OpenAiCompatible {
|
||||
|
||||
public function id(): string { return 'nvidia'; }
|
||||
public function label(): string { return 'NVIDIA Build'; }
|
||||
public function default_model(): string { return 'nvidia/nemotron-3-ultra-550b-a55b'; }
|
||||
protected function base_url(): string { return 'https://integrate.api.nvidia.com/v1'; }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Ai\Providers;
|
||||
|
||||
use AAP\Modules\Ai\Provider;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Shared implementation for every OpenAI-compatible /chat/completions API
|
||||
* (NVIDIA Build, OpenAI, OpenRouter and custom endpoints).
|
||||
*/
|
||||
abstract class OpenAiCompatible implements Provider {
|
||||
|
||||
protected string $api_key;
|
||||
protected string $model;
|
||||
protected int $timeout;
|
||||
|
||||
public function __construct(string $api_key, string $model, int $timeout) {
|
||||
$this->api_key = trim($api_key);
|
||||
$this->model = trim($model) !== '' ? trim($model) : $this->default_model();
|
||||
$this->timeout = max(10, $timeout);
|
||||
}
|
||||
|
||||
abstract protected function base_url(): string;
|
||||
|
||||
/** @return array<string,string> */
|
||||
protected function extra_headers(): array {
|
||||
return [];
|
||||
}
|
||||
|
||||
public function is_configured(): bool {
|
||||
return $this->api_key !== '';
|
||||
}
|
||||
|
||||
public function complete(string $prompt, int $max_tokens = 4096, float $temperature = 0.7): ?string {
|
||||
if (!$this->is_configured()) {
|
||||
Logger::error('AI provider "' . $this->id() . '" is not configured (missing API key).', 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = rtrim($this->base_url(), '/') . '/chat/completions';
|
||||
|
||||
$response = wp_remote_post($url, [
|
||||
'timeout' => $this->timeout,
|
||||
'headers' => array_merge([
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
], $this->extra_headers()),
|
||||
'body' => wp_json_encode([
|
||||
'model' => $this->model,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'temperature' => $temperature,
|
||||
'max_tokens' => $max_tokens,
|
||||
'stream' => false,
|
||||
]),
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
Logger::warn('AI request failed (' . $this->id() . '): ' . $response->get_error_message(), 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = (int) wp_remote_retrieve_response_code($response);
|
||||
$body = (string) wp_remote_retrieve_body($response);
|
||||
|
||||
if ($code < 200 || $code >= 300) {
|
||||
Logger::warn('AI HTTP ' . $code . ' (' . $this->id() . '): ' . substr($body, 0, 300), 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($body, true);
|
||||
$content = $data['choices'][0]['message']['content'] ?? null;
|
||||
|
||||
// Reasoning models (e.g. Nemotron, o1-style) spend the token budget on
|
||||
// "reasoning_content" first. If that is exhausted, the API still answers
|
||||
// 200 but leaves "content" null and stops with finish_reason "length".
|
||||
if (!is_string($content) || trim($content) === '') {
|
||||
$finish = (string) ($data['choices'][0]['finish_reason'] ?? '');
|
||||
$reason = strlen((string) ($data['choices'][0]['message']['reasoning_content'] ?? ''));
|
||||
$tokens = (string) ($data['usage']['completion_tokens'] ?? '?');
|
||||
|
||||
Logger::warn(
|
||||
'AI returned empty content (' . $this->id() . "): finish_reason={$finish}, "
|
||||
. "completion_tokens={$tokens}, reasoning_chars={$reason}"
|
||||
. ($finish === 'length' ? ' — the token budget was consumed by reasoning; raise max_tokens.' : ''),
|
||||
'ai'
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Ai\Providers;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class OpenAiProvider extends OpenAiCompatible {
|
||||
|
||||
public function id(): string { return 'openai'; }
|
||||
public function label(): string { return 'OpenAI'; }
|
||||
public function default_model(): string { return 'gpt-4o-mini'; }
|
||||
protected function base_url(): string { return 'https://api.openai.com/v1'; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Ai\Providers;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class OpenRouterProvider extends OpenAiCompatible {
|
||||
|
||||
public function id(): string { return 'openrouter'; }
|
||||
public function label(): string { return 'OpenRouter'; }
|
||||
public function default_model(): string { return 'openai/gpt-4o-mini'; }
|
||||
protected function base_url(): string { return 'https://openrouter.ai/api/v1'; }
|
||||
|
||||
protected function extra_headers(): array {
|
||||
return [
|
||||
'HTTP-Referer' => home_url('/'),
|
||||
'X-Title' => 'Automatic AI Posts',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Ai\Providers;
|
||||
|
||||
use AAP\Modules\Ai\Provider;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Google Vertex AI (Gemini) using a service account JSON key.
|
||||
*
|
||||
* Flow: service account JSON -> signed JWT -> OAuth access token -> generateContent.
|
||||
*/
|
||||
final class VertexProvider implements Provider {
|
||||
|
||||
private string $project;
|
||||
private string $location;
|
||||
private string $service_account;
|
||||
private string $model;
|
||||
private int $timeout;
|
||||
|
||||
public function __construct(string $project, string $location, string $service_account, string $model, int $timeout) {
|
||||
$this->project = trim($project);
|
||||
$this->location = trim($location) !== '' ? trim($location) : 'us-central1';
|
||||
$this->service_account = trim($service_account);
|
||||
$this->model = trim($model) !== '' ? trim($model) : $this->default_model();
|
||||
$this->timeout = max(10, $timeout);
|
||||
}
|
||||
|
||||
public function id(): string { return 'vertex'; }
|
||||
public function label(): string { return 'Google Vertex AI'; }
|
||||
public function default_model(): string { return 'gemini-2.0-flash'; }
|
||||
|
||||
public function is_configured(): bool {
|
||||
return $this->project !== '' && $this->service_account !== '';
|
||||
}
|
||||
|
||||
public function complete(string $prompt, int $max_tokens = 4096, float $temperature = 0.7): ?string {
|
||||
if (!$this->is_configured()) {
|
||||
Logger::error('Vertex AI is not configured (project or service account missing).', 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = $this->access_token();
|
||||
if ($token === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:generateContent',
|
||||
rawurlencode($this->location),
|
||||
rawurlencode($this->project),
|
||||
rawurlencode($this->location),
|
||||
rawurlencode($this->model)
|
||||
);
|
||||
|
||||
$response = wp_remote_post($url, [
|
||||
'timeout' => $this->timeout,
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'body' => wp_json_encode([
|
||||
'contents' => [
|
||||
['role' => 'user', 'parts' => [['text' => $prompt]]],
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => $temperature,
|
||||
'maxOutputTokens' => $max_tokens,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
Logger::warn('Vertex request failed: ' . $response->get_error_message(), 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = (int) wp_remote_retrieve_response_code($response);
|
||||
$body = (string) wp_remote_retrieve_body($response);
|
||||
|
||||
if ($code < 200 || $code >= 300) {
|
||||
Logger::warn('Vertex HTTP ' . $code . ': ' . substr($body, 0, 300), 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($body, true);
|
||||
$content = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
|
||||
return is_string($content) && trim($content) !== '' ? trim($content) : null;
|
||||
}
|
||||
|
||||
private function access_token(): ?string {
|
||||
$cache_key = 'aap_vertex_token_' . md5($this->project . $this->service_account);
|
||||
$cached = get_transient($cache_key);
|
||||
if (is_string($cached) && $cached !== '') {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$sa = json_decode($this->service_account, true);
|
||||
if (!is_array($sa) || empty($sa['client_email']) || empty($sa['private_key'])) {
|
||||
Logger::error('Vertex service account JSON is invalid.', 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$header = ['alg' => 'RS256', 'typ' => 'JWT'];
|
||||
$claims = [
|
||||
'iss' => $sa['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'iat' => $now,
|
||||
'exp' => $now + 3600,
|
||||
];
|
||||
|
||||
$b64 = static fn(array $data): string => rtrim(strtr(base64_encode((string) wp_json_encode($data)), '+/', '-_'), '=');
|
||||
$input = $b64($header) . '.' . $b64($claims);
|
||||
|
||||
$signature = '';
|
||||
if (!openssl_sign($input, $signature, $sa['private_key'], OPENSSL_ALGO_SHA256)) {
|
||||
Logger::error('Vertex: could not sign the JWT with the service account key.', 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
$jwt = $input . '.' . rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
|
||||
|
||||
$response = wp_remote_post('https://oauth2.googleapis.com/token', [
|
||||
'timeout' => $this->timeout,
|
||||
'body' => [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
],
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
Logger::warn('Vertex token request failed: ' . $response->get_error_message(), 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode((string) wp_remote_retrieve_body($response), true);
|
||||
$token = $data['access_token'] ?? null;
|
||||
|
||||
if (!is_string($token) || $token === '') {
|
||||
Logger::warn('Vertex token response without access_token.', 'ai');
|
||||
return null;
|
||||
}
|
||||
|
||||
set_transient($cache_key, $token, 55 * MINUTE_IN_SECONDS);
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Content;
|
||||
|
||||
use AAP\Core\Module;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Full article extraction from the source page.
|
||||
*/
|
||||
final class ContentModule extends Module {
|
||||
|
||||
public function id(): string {
|
||||
return 'content';
|
||||
}
|
||||
|
||||
public function dependencies(): array {
|
||||
return ['logs'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Content;
|
||||
|
||||
use AAP\Modules\Logs\Logger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Extracts the main article text from the original page.
|
||||
*/
|
||||
final class Scraper {
|
||||
|
||||
public function scrape(string $url): ?string {
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'timeout' => 15,
|
||||
'user-agent' => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||||
'headers' => ['Accept-Language' => 'pt-BR,pt;q=0.9,en;q=0.8'],
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
Logger::warn('Scraper request failed: ' . $response->get_error_message(), 'content');
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) wp_remote_retrieve_response_code($response) !== 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$html = (string) wp_remote_retrieve_body($response);
|
||||
|
||||
return $html !== '' ? $this->extract($html) : null;
|
||||
}
|
||||
|
||||
private function extract(string $html): ?string {
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
$doc = new \DOMDocument();
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_NOWARNING | LIBXML_NOERROR);
|
||||
libxml_clear_errors();
|
||||
|
||||
$xpath = new \DOMXPath($doc);
|
||||
|
||||
foreach ($xpath->query('//script | //style | //nav | //header | //footer | //aside | //form | //iframe') as $node) {
|
||||
$node->parentNode?->removeChild($node);
|
||||
}
|
||||
|
||||
$queries = [
|
||||
'//*[@itemprop="articleBody"]',
|
||||
'//article',
|
||||
'//*[contains(@class,"article-body")]',
|
||||
'//*[contains(@class,"article-content")]',
|
||||
'//*[contains(@class,"articleBody")]',
|
||||
'//*[contains(@class,"post-content")]',
|
||||
'//*[contains(@class,"entry-content")]',
|
||||
'//*[contains(@class,"story-body")]',
|
||||
'//*[contains(@class,"content-body")]',
|
||||
'//*[contains(@class,"materia-conteudo")]',
|
||||
'//*[contains(@class,"body-content")]',
|
||||
'//main',
|
||||
];
|
||||
|
||||
foreach ($queries as $query) {
|
||||
$nodes = $xpath->query($query);
|
||||
if (!$nodes || $nodes->length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = $this->to_text($nodes->item(0));
|
||||
if (strlen($text) >= 300) {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function to_text(\DOMNode $node): string {
|
||||
$text = (string) ($node->textContent ?? '');
|
||||
$text = (string) preg_replace('/[ \t]+/', ' ', $text);
|
||||
$text = (string) preg_replace('/\n{3,}/', "\n\n", $text);
|
||||
return trim($text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Editor;
|
||||
|
||||
use AAP\Core\Module;
|
||||
use AAP\Modules\Images\ImageService;
|
||||
use AAP\Modules\I18n\Lang;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* "Search a new cover image" button on the post edit screen.
|
||||
*
|
||||
* Runs the same steps as the pipeline (AI query + metadata, DuckDuckGo search,
|
||||
* sideload) but on demand for a post that already exists. Useful when the
|
||||
* automatically chosen image is not good enough.
|
||||
*/
|
||||
final class EditorModule extends Module {
|
||||
|
||||
public function id(): string {
|
||||
return 'editor';
|
||||
}
|
||||
|
||||
public function dependencies(): array {
|
||||
return ['logs', 'ai', 'images', 'posts'];
|
||||
}
|
||||
|
||||
public function boot(): void {
|
||||
add_action('add_meta_boxes', [$this, 'meta_box']);
|
||||
add_action('admin_enqueue_scripts', [$this, 'assets']);
|
||||
add_action('wp_ajax_aap_refresh_image', [$this, 'ajax_refresh_image']);
|
||||
}
|
||||
|
||||
public function meta_box(): void {
|
||||
$screen = get_current_screen();
|
||||
|
||||
if (!$screen || $screen->base !== 'post') {
|
||||
return;
|
||||
}
|
||||
|
||||
add_meta_box(
|
||||
'aap-refresh-image',
|
||||
Lang::t('editor_box_title'),
|
||||
[$this, 'render_meta_box'],
|
||||
$screen->post_type,
|
||||
'side',
|
||||
'default'
|
||||
);
|
||||
}
|
||||
|
||||
public function render_meta_box($post): void {
|
||||
$has_image = has_post_thumbnail($post->ID);
|
||||
?>
|
||||
<p class="aap-refresh-desc">
|
||||
<?php echo esc_html(Lang::t('editor_desc')); ?>
|
||||
</p>
|
||||
<button type="button" class="button button-secondary aap-refresh-btn" id="aap-refresh-image-btn"
|
||||
data-post="<?php echo esc_attr((string) $post->ID); ?>"
|
||||
data-nonce="<?php echo esc_attr(wp_create_nonce('aap_refresh_image')); ?>">
|
||||
<?php echo esc_html(Lang::t($has_image ? 'editor_btn_has_image' : 'editor_btn_no_image')); ?>
|
||||
</button>
|
||||
<span class="aap-refresh-feedback" id="aap-refresh-image-feedback" role="status" aria-live="polite"></span>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function assets(string $hook): void {
|
||||
if ($hook !== 'post.php' && $hook !== 'post-new.php') {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style('aap-editor', AAP_PLUGIN_URL . 'admin/editor.css', [], AAP_VERSION);
|
||||
wp_enqueue_script('aap-editor', AAP_PLUGIN_URL . 'admin/editor.js', ['jquery'], AAP_VERSION, true);
|
||||
|
||||
wp_localize_script('aap-editor', 'aapEditor', [
|
||||
'ajaxurl' => admin_url('admin-ajax.php'),
|
||||
'i18n' => [
|
||||
'working' => Lang::t('editor_working'),
|
||||
'success' => Lang::t('editor_success'),
|
||||
'no_image' => Lang::t('editor_no_image'),
|
||||
'error' => Lang::t('editor_error'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function ajax_refresh_image(): void {
|
||||
check_ajax_referer('aap_refresh_image', 'nonce');
|
||||
|
||||
$post_id = (int) ($_POST['post_id'] ?? 0);
|
||||
|
||||
if ($post_id <= 0 || !current_user_can('edit_post', $post_id)) {
|
||||
wp_send_json_error(['message' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
$post = get_post($post_id);
|
||||
|
||||
if (!$post) {
|
||||
wp_send_json_error(['message' => 'missing_post'], 404);
|
||||
}
|
||||
|
||||
$ai = new \AAP\Modules\Ai\AiService();
|
||||
|
||||
if (!$ai->is_ready()) {
|
||||
Logger::error("Image refresh for post {$post_id}: AI provider is not configured.", 'images');
|
||||
wp_send_json_error(['message' => 'ai_not_configured']);
|
||||
}
|
||||
|
||||
$title = (string) get_the_title($post);
|
||||
$content = (string) $post->post_content;
|
||||
$language = \AAP\Support\Languages::current_post();
|
||||
|
||||
Logger::info("Manual image refresh requested for post {$post_id}: \"{$title}\".", 'images');
|
||||
|
||||
$images = new ImageService($ai);
|
||||
$plan = $images->resolve($title, $content, $language);
|
||||
|
||||
if (empty($plan['urls'])) {
|
||||
Logger::warn("Image refresh for post {$post_id}: no candidate image found.", 'images');
|
||||
wp_send_json_success(['updated' => false, 'reason' => 'no_image']);
|
||||
}
|
||||
|
||||
$attachment_id = $images->attach($plan['urls'], $post_id, $title, $plan['meta']);
|
||||
|
||||
if ($attachment_id <= 0) {
|
||||
Logger::warn("Image refresh for post {$post_id}: could not download any candidate.", 'images');
|
||||
wp_send_json_success(['updated' => false, 'reason' => 'download_failed']);
|
||||
}
|
||||
|
||||
set_post_thumbnail($post_id, $attachment_id);
|
||||
|
||||
Logger::info(
|
||||
"Cover image of post {$post_id} replaced (attachment ID {$attachment_id}, query \"{$plan['query']}\").",
|
||||
'images'
|
||||
);
|
||||
|
||||
wp_send_json_success([
|
||||
'updated' => true,
|
||||
'attachment_id' => $attachment_id,
|
||||
'query' => $plan['query'],
|
||||
'thumbnail' => (string) get_the_post_thumbnail_url($post_id, 'medium'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Feeds;
|
||||
|
||||
use AAP\Core\Module;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* RSS + GNews article sources.
|
||||
*/
|
||||
final class FeedsModule extends Module {
|
||||
|
||||
public function id(): string {
|
||||
return 'feeds';
|
||||
}
|
||||
|
||||
public function dependencies(): array {
|
||||
return ['logs', 'i18n'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Feeds;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
use AAP\Support\Languages;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Fetches headlines from the GNews.io API.
|
||||
*/
|
||||
final class GnewsFetcher {
|
||||
|
||||
private const API_URL = 'https://gnews.io/api/v4/top-headlines';
|
||||
|
||||
public const CATEGORIES = [
|
||||
'general' => 'General',
|
||||
'world' => 'World',
|
||||
'nation' => 'Nation',
|
||||
'business' => 'Business',
|
||||
'technology' => 'Technology',
|
||||
'entertainment' => 'Entertainment',
|
||||
'sports' => 'Sports',
|
||||
'science' => 'Science',
|
||||
'health' => 'Health',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<int, array{title:string,content:string,link:string,source:string}>
|
||||
*/
|
||||
public function fetch_all(): array {
|
||||
$api_key = (string) Options::get('aap_gnews_api_key', '');
|
||||
$categories = (array) Options::get('aap_gnews_categories', []);
|
||||
$max = max(1, Options::int('aap_gnews_max'));
|
||||
|
||||
if ($api_key === '') {
|
||||
Logger::warn('GNews: API key not configured, skipping.', 'feeds');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (empty($categories)) {
|
||||
Logger::warn('GNews: no category selected, skipping.', 'feeds');
|
||||
return [];
|
||||
}
|
||||
|
||||
$articles = [];
|
||||
$first = true;
|
||||
|
||||
foreach ($categories as $category) {
|
||||
if (!isset(self::CATEGORIES[$category])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$first) {
|
||||
sleep(2); // free plan rate limit
|
||||
}
|
||||
$first = false;
|
||||
|
||||
$articles = array_merge($articles, $this->fetch_category($api_key, (string) $category, $max));
|
||||
}
|
||||
|
||||
return $articles;
|
||||
}
|
||||
|
||||
private function fetch_category(string $api_key, string $category, int $max): array {
|
||||
[$lang, $country] = Languages::gnews(Languages::current_post());
|
||||
|
||||
$url = add_query_arg([
|
||||
'category' => $category,
|
||||
'lang' => $lang,
|
||||
'country' => $country,
|
||||
'max' => $max,
|
||||
'apikey' => $api_key,
|
||||
], self::API_URL);
|
||||
|
||||
Logger::info("GNews: fetching \"{$category}\" (lang={$lang}, country={$country}, max={$max})", 'feeds');
|
||||
|
||||
$response = wp_remote_get($url, ['timeout' => 15]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
Logger::error("GNews request failed ({$category}): " . $response->get_error_message(), 'feeds');
|
||||
return [];
|
||||
}
|
||||
|
||||
$code = (int) wp_remote_retrieve_response_code($response);
|
||||
$body = (string) wp_remote_retrieve_body($response);
|
||||
|
||||
if ($code === 429) {
|
||||
Logger::warn("GNews rate limit reached ({$category}); retrying in 10s.", 'feeds');
|
||||
sleep(10);
|
||||
$response = wp_remote_get($url, ['timeout' => 15]);
|
||||
if (!is_wp_error($response)) {
|
||||
$code = (int) wp_remote_retrieve_response_code($response);
|
||||
$body = (string) wp_remote_retrieve_body($response);
|
||||
}
|
||||
}
|
||||
|
||||
if ($code !== 200) {
|
||||
Logger::error("GNews HTTP {$code} ({$category}): " . substr($body, 0, 200), 'feeds');
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if (!isset($data['articles']) || !is_array($data['articles'])) {
|
||||
Logger::error("GNews response without \"articles\" ({$category}).", 'feeds');
|
||||
return [];
|
||||
}
|
||||
|
||||
$articles = [];
|
||||
|
||||
foreach ($data['articles'] as $item) {
|
||||
$title = trim((string) ($item['title'] ?? ''));
|
||||
$content = trim((string) ($item['content'] ?? $item['description'] ?? ''));
|
||||
$link = trim((string) ($item['url'] ?? ''));
|
||||
$source = trim((string) ($item['source']['name'] ?? 'GNews'));
|
||||
|
||||
if ($title === '' || $content === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$articles[] = [
|
||||
'title' => $title,
|
||||
'content' => $content,
|
||||
'link' => $link,
|
||||
'source' => $source,
|
||||
];
|
||||
}
|
||||
|
||||
Logger::info("GNews \"{$category}\": " . count($articles) . ' article(s) collected.', 'feeds');
|
||||
return $articles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Feeds;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Fetches and normalizes RSS/Atom feeds.
|
||||
*/
|
||||
final class RssFetcher {
|
||||
|
||||
/**
|
||||
* @param string[] $feed_urls
|
||||
* @return array<int, array{title:string,content:string,link:string,source:string}>
|
||||
*/
|
||||
public function fetch_all(array $feed_urls): array {
|
||||
$articles = [];
|
||||
$max_items = max(1, Options::int('aap_feed_max_items'));
|
||||
|
||||
foreach ($feed_urls as $url) {
|
||||
$articles = array_merge($articles, $this->fetch_feed($url, $max_items));
|
||||
}
|
||||
|
||||
return $articles;
|
||||
}
|
||||
|
||||
private function fetch_feed(string $url, int $max_items): array {
|
||||
Logger::info("Fetching feed: {$url} (max {$max_items} items)", 'feeds');
|
||||
|
||||
$response = wp_remote_get($url, ['timeout' => 15]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
Logger::error("Feed request failed ({$url}): " . $response->get_error_message(), 'feeds');
|
||||
return [];
|
||||
}
|
||||
|
||||
$body = (string) wp_remote_retrieve_body($response);
|
||||
if ($body === '') {
|
||||
Logger::warn("Feed returned an empty body: {$url}", 'feeds');
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->parse($body, $url, $max_items);
|
||||
}
|
||||
|
||||
private function parse(string $xml_body, string $source_url, int $max_items): array {
|
||||
libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_string($xml_body);
|
||||
libxml_clear_errors();
|
||||
|
||||
if (!$xml) {
|
||||
Logger::error("Could not parse feed XML: {$source_url}", 'feeds');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Atom feeds wrap entries in <entry> at the root instead of <item> inside <channel>.
|
||||
$is_atom = $xml->getName() === 'feed';
|
||||
|
||||
if ($is_atom) {
|
||||
$source_name = $this->clean((string) ($xml->title ?? '')) ?: parse_url($source_url, PHP_URL_HOST);
|
||||
$items = $xml->entry;
|
||||
} else {
|
||||
$source_name = (string) ($xml->channel->title ?? parse_url($source_url, PHP_URL_HOST));
|
||||
$items = $xml->channel->item ?? [];
|
||||
}
|
||||
|
||||
$articles = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (count($articles) >= $max_items) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($is_atom) {
|
||||
$title = $this->clean((string) $item->title);
|
||||
$link = $this->atom_link($item);
|
||||
$desc = $this->clean((string) $item->summary);
|
||||
$full = $this->clean((string) $item->content);
|
||||
} else {
|
||||
$title = $this->clean((string) $item->title);
|
||||
$link = $this->clean((string) $item->link);
|
||||
$desc = $this->clean((string) $item->description);
|
||||
|
||||
$content_ns = $item->children('content', true);
|
||||
$full = $content_ns ? $this->clean((string) $content_ns->encoded) : '';
|
||||
}
|
||||
|
||||
$content = $full !== '' ? $full : $desc;
|
||||
|
||||
if ($title === '' || $content === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$articles[] = [
|
||||
'title' => $title,
|
||||
'content' => $content,
|
||||
'link' => $link,
|
||||
'source' => $source_name,
|
||||
];
|
||||
}
|
||||
|
||||
Logger::info("Feed \"{$source_name}\": " . count($articles) . ' article(s) collected.', 'feeds');
|
||||
return $articles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atom entries carry several <link> tags; the alternate one is the article URL.
|
||||
*/
|
||||
private function atom_link(\SimpleXMLElement $entry): string {
|
||||
$fallback = '';
|
||||
|
||||
foreach ($entry->link as $link) {
|
||||
$href = (string) $link['href'];
|
||||
if ($href === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rel = (string) $link['rel'];
|
||||
|
||||
if ($rel === 'alternate' || $rel === '') {
|
||||
return esc_url_raw($href);
|
||||
}
|
||||
|
||||
if ($fallback === '') {
|
||||
$fallback = $href;
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback !== '' ? esc_url_raw($fallback) : $this->clean((string) $entry->id);
|
||||
}
|
||||
|
||||
private function clean(string $text): string {
|
||||
$text = wp_strip_all_tags($text);
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
return trim($text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
namespace AAP\Modules\I18n;
|
||||
|
||||
use AAP\Core\Module;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Provides translated admin strings (see Lang).
|
||||
*/
|
||||
final class I18nModule extends Module {
|
||||
|
||||
public function id(): string {
|
||||
return 'i18n';
|
||||
}
|
||||
|
||||
public function boot(): void {
|
||||
Lang::load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
namespace AAP\Modules\I18n;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Loads the admin UI strings from languages/{code}.php.
|
||||
*
|
||||
* The app language is independent from the WordPress locale, so the user can
|
||||
* run the panel in Portuguese while the site is in English. Missing keys fall
|
||||
* back to English.
|
||||
*/
|
||||
final class Lang {
|
||||
|
||||
private static array $strings = [];
|
||||
private static array $fallback = [];
|
||||
private static bool $loaded = false;
|
||||
private static string $locale = '';
|
||||
|
||||
public static function load(): void {
|
||||
if (self::$loaded) {
|
||||
return;
|
||||
}
|
||||
self::$loaded = true;
|
||||
|
||||
self::$locale = \AAP\Support\Languages::current_app();
|
||||
|
||||
$dir = AAP_PLUGIN_DIR . 'languages/';
|
||||
|
||||
$fallback_file = $dir . 'en.php';
|
||||
if (is_readable($fallback_file)) {
|
||||
$data = require $fallback_file;
|
||||
self::$fallback = is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
$file = $dir . self::$locale . '.php';
|
||||
if (is_readable($file)) {
|
||||
$data = require $file;
|
||||
self::$strings = is_array($data) ? $data : [];
|
||||
}
|
||||
}
|
||||
|
||||
public static function locale(): string {
|
||||
self::load();
|
||||
return self::$locale;
|
||||
}
|
||||
|
||||
public static function t(string $key): string {
|
||||
self::load();
|
||||
|
||||
$value = self::$strings[$key] ?? self::$fallback[$key] ?? $key;
|
||||
|
||||
return is_string($value) ? $value : $key;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function ta(string $key): array {
|
||||
self::load();
|
||||
|
||||
$value = self::$strings[$key] ?? self::$fallback[$key] ?? [];
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function all(): array {
|
||||
self::load();
|
||||
return array_merge(self::$fallback, self::$strings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Images;
|
||||
|
||||
use AAP\Modules\Logs\Logger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* DuckDuckGo image search (no API key required).
|
||||
*
|
||||
* The query itself is produced by the AI (see AiService::image_plan), so the
|
||||
* blog owner's image instructions influence what is searched.
|
||||
*/
|
||||
final class DuckDuckGo {
|
||||
|
||||
private int $timeout;
|
||||
|
||||
public function __construct(int $timeout = 30) {
|
||||
$this->timeout = max(10, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] image URLs (best first)
|
||||
*/
|
||||
public function search(string $query, int $limit = 10): array {
|
||||
$query = trim($query);
|
||||
if ($query === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$encoded = urlencode($query);
|
||||
$token = $this->vqd($encoded);
|
||||
|
||||
if ($token === '') {
|
||||
Logger::warn('DuckDuckGo: vqd token not found for "' . $query . '".', 'images');
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->fetch($encoded, $token, $limit);
|
||||
}
|
||||
|
||||
private function vqd(string $encoded_query): string {
|
||||
$response = wp_remote_get(
|
||||
'https://duckduckgo.com/?q=' . $encoded_query . '&iax=images&ia=images',
|
||||
[
|
||||
'timeout' => $this->timeout,
|
||||
'user-agent' => 'Mozilla/5.0 (compatible; automatic-ai-posts/2.0)',
|
||||
]
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
Logger::warn('DuckDuckGo: token request failed: ' . $response->get_error_message(), 'images');
|
||||
return '';
|
||||
}
|
||||
|
||||
$body = (string) wp_remote_retrieve_body($response);
|
||||
|
||||
if (preg_match('/vqd=([\d-]+)/', $body, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function fetch(string $encoded_query, string $token, int $limit): array {
|
||||
$url = add_query_arg([
|
||||
'l' => 'us-en',
|
||||
'o' => 'json',
|
||||
'q' => $encoded_query,
|
||||
'vqd' => $token,
|
||||
'f' => ',,,,,',
|
||||
'p' => '1',
|
||||
], 'https://duckduckgo.com/i.js');
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'timeout' => $this->timeout,
|
||||
'headers' => [
|
||||
'Referer' => 'https://duckduckgo.com/',
|
||||
'User-Agent' => 'Mozilla/5.0 (compatible; automatic-ai-posts/2.0)',
|
||||
],
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
Logger::warn('DuckDuckGo: image request failed: ' . $response->get_error_message(), 'images');
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode((string) wp_remote_retrieve_body($response), true);
|
||||
|
||||
if (!isset($data['results']) || !is_array($data['results'])) {
|
||||
Logger::warn('DuckDuckGo: response without results.', 'images');
|
||||
return [];
|
||||
}
|
||||
|
||||
$urls = [];
|
||||
foreach ($data['results'] as $result) {
|
||||
$image = (string) ($result['image'] ?? '');
|
||||
if ($image !== '') {
|
||||
$urls[] = $image;
|
||||
if (count($urls) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Images;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Modules\Ai\AiService;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Image pipeline: AI builds the search query + metadata, DuckDuckGo finds
|
||||
* candidates, Sideloader stores the first one that downloads.
|
||||
*/
|
||||
final class ImageService {
|
||||
|
||||
private AiService $ai;
|
||||
private DuckDuckGo $search;
|
||||
private Sideloader $sideloader;
|
||||
|
||||
public function __construct(?AiService $ai = null) {
|
||||
// Image requests get their own (short) timeout: the AI timeout can be
|
||||
// several minutes, which would hang the pipeline on a slow image host.
|
||||
$this->ai = $ai ?? new AiService();
|
||||
$this->search = new DuckDuckGo(20);
|
||||
$this->sideloader = new Sideloader(45);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{query:string,urls:string[],meta:array{alt:string,caption:string,description:string}}
|
||||
*/
|
||||
public function resolve(string $title, string $content, string $language): array {
|
||||
$plan = $this->ai->image_plan($title, $content, $language);
|
||||
$query = $plan['query'] ?? $title;
|
||||
|
||||
Logger::info("Image search query: \"{$query}\"", 'images');
|
||||
|
||||
$urls = $this->search->search($query);
|
||||
|
||||
// Fallback: the AI query may be too specific; retry with the article title.
|
||||
if (empty($urls) && $query !== $title) {
|
||||
Logger::warn('No image found for the AI query; retrying with the article title.', 'images');
|
||||
$urls = $this->search->search($title);
|
||||
}
|
||||
|
||||
$alt = trim((string) ($plan['alt'] ?? ''));
|
||||
|
||||
$meta = [
|
||||
'alt' => $alt !== '' ? $alt : $title,
|
||||
'caption' => (string) ($plan['caption'] ?? ''),
|
||||
'description' => (string) ($plan['description'] ?? ''),
|
||||
];
|
||||
|
||||
return [
|
||||
'query' => $query,
|
||||
'urls' => $urls,
|
||||
'meta' => $meta,
|
||||
];
|
||||
}
|
||||
|
||||
public function attach(array $urls, int $post_id, string $title, array $meta): int {
|
||||
return $this->sideloader->attach($urls, $post_id, $title, $meta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Images;
|
||||
|
||||
use AAP\Core\Module;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Cover image search (DuckDuckGo) and sideloading.
|
||||
*/
|
||||
final class ImagesModule extends Module {
|
||||
|
||||
public function id(): string {
|
||||
return 'images';
|
||||
}
|
||||
|
||||
public function dependencies(): array {
|
||||
return ['logs', 'ai'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Images;
|
||||
|
||||
use AAP\Modules\Logs\Logger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Downloads a remote image into the media library and applies its metadata.
|
||||
*/
|
||||
final class Sideloader {
|
||||
|
||||
private int $timeout;
|
||||
|
||||
public function __construct(int $timeout = 60) {
|
||||
$this->timeout = max(10, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries every URL until one downloads successfully.
|
||||
*
|
||||
* @param string[] $urls
|
||||
* @param array{alt:string,caption:string,description:string}|null $meta
|
||||
*/
|
||||
public function attach(array $urls, int $post_id, string $title, ?array $meta = null): int {
|
||||
if (empty($urls)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/media.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||
|
||||
$total = count($urls);
|
||||
|
||||
foreach (array_values($urls) as $index => $url) {
|
||||
$attempt = $index + 1;
|
||||
Logger::info("Downloading cover image ({$attempt}/{$total}): {$url}", 'images');
|
||||
|
||||
$tmp = download_url($url, $this->timeout);
|
||||
|
||||
if (is_wp_error($tmp)) {
|
||||
Logger::warn("Image attempt {$attempt} failed: " . $tmp->get_error_message(), 'images');
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = [
|
||||
'name' => sanitize_file_name($title) . '.' . $this->extension($url),
|
||||
'tmp_name' => $tmp,
|
||||
];
|
||||
|
||||
$attachment_id = media_handle_sideload($file, $post_id);
|
||||
|
||||
if (is_wp_error($attachment_id)) {
|
||||
@unlink($tmp);
|
||||
Logger::warn("Image attempt {$attempt} failed on sideload: " . $attachment_id->get_error_message(), 'images');
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger::info("Cover image saved in the media library (ID {$attachment_id}).", 'images');
|
||||
|
||||
if (!empty($meta)) {
|
||||
wp_update_post([
|
||||
'ID' => $attachment_id,
|
||||
'post_excerpt' => (string) ($meta['caption'] ?? ''),
|
||||
'post_content' => (string) ($meta['description'] ?? ''),
|
||||
]);
|
||||
|
||||
if (!empty($meta['alt'])) {
|
||||
update_post_meta($attachment_id, '_wp_attachment_image_alt', (string) $meta['alt']);
|
||||
}
|
||||
|
||||
Logger::info('Image metadata applied (alt, caption, description).', 'images');
|
||||
}
|
||||
|
||||
return (int) $attachment_id;
|
||||
}
|
||||
|
||||
Logger::warn("Could not download any of the {$total} candidate image(s).", 'images');
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function extension(string $url): string {
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
$ext = strtolower(pathinfo((string) $path, PATHINFO_EXTENSION));
|
||||
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
|
||||
return in_array($ext, $allowed, true) ? $ext : 'jpg';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Logs;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Persists logs in the database (table {prefix}aap_logs).
|
||||
*
|
||||
* Levels:
|
||||
* info — normal operation
|
||||
* warn — recoverable error (a step failed but the pipeline continued)
|
||||
* error — unrecoverable error (the step/run could not continue)
|
||||
*/
|
||||
final class LogRepository {
|
||||
|
||||
public const TABLE_VERSION = '1';
|
||||
public const RETENTION_DAYS = 30;
|
||||
|
||||
public static function table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'aap_logs';
|
||||
}
|
||||
|
||||
public static function install(): void {
|
||||
global $wpdb;
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
$table = self::table();
|
||||
$collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE {$table} (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
created_at DATETIME NOT NULL,
|
||||
level VARCHAR(10) NOT NULL DEFAULT 'info',
|
||||
message TEXT NOT NULL,
|
||||
context VARCHAR(64) NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY created_at (created_at),
|
||||
KEY level (level)
|
||||
) {$collate};";
|
||||
|
||||
dbDelta($sql);
|
||||
|
||||
update_option('aap_logs_table_version', self::TABLE_VERSION);
|
||||
}
|
||||
|
||||
public static function maybe_install(): void {
|
||||
if (get_option('aap_logs_table_version') !== self::TABLE_VERSION) {
|
||||
self::install();
|
||||
}
|
||||
}
|
||||
|
||||
public static function add(string $level, string $message, string $context = ''): void {
|
||||
global $wpdb;
|
||||
|
||||
$level = in_array($level, ['info', 'warn', 'error'], true) ? $level : 'info';
|
||||
|
||||
$wpdb->insert(
|
||||
self::table(),
|
||||
[
|
||||
'created_at' => current_time('mysql'),
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
'context' => $context !== '' ? substr($context, 0, 64) : null,
|
||||
],
|
||||
['%s', '%s', '%s', '%s']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rows: array<int, object>, total: int, has_more: bool}
|
||||
*/
|
||||
public static function page(int $page = 1, int $per_page = 50, string $level = ''): array {
|
||||
global $wpdb;
|
||||
|
||||
$page = max(1, $page);
|
||||
$per_page = min(200, max(1, $per_page));
|
||||
$offset = ($page - 1) * $per_page;
|
||||
$table = self::table();
|
||||
|
||||
$where = '';
|
||||
$params = [];
|
||||
|
||||
if (in_array($level, ['info', 'warn', 'error'], true)) {
|
||||
$where = 'WHERE level = %s';
|
||||
$params[] = $level;
|
||||
}
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM {$table} {$where}";
|
||||
$total = (int) ($params
|
||||
? $wpdb->get_var($wpdb->prepare($count_sql, $params))
|
||||
: $wpdb->get_var($count_sql));
|
||||
|
||||
$list_sql = "SELECT id, created_at, level, message, context FROM {$table} {$where} ORDER BY id DESC LIMIT %d OFFSET %d";
|
||||
$args = array_merge($params, [$per_page, $offset]);
|
||||
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $args));
|
||||
|
||||
return [
|
||||
'rows' => is_array($rows) ? $rows : [],
|
||||
'total' => $total,
|
||||
'has_more' => ($offset + count((array) $rows)) < $total,
|
||||
];
|
||||
}
|
||||
|
||||
/** Removes entries older than the retention window. */
|
||||
public static function purge(): int {
|
||||
global $wpdb;
|
||||
|
||||
$limit = wp_date('Y-m-d H:i:s', time() - (self::RETENTION_DAYS * DAY_IN_SECONDS));
|
||||
|
||||
return (int) $wpdb->query(
|
||||
$wpdb->prepare('DELETE FROM ' . self::table() . ' WHERE created_at < %s', $limit)
|
||||
);
|
||||
}
|
||||
|
||||
public static function total(): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var('SELECT COUNT(*) FROM ' . self::table());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Logs;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Static logging facade used across every module.
|
||||
*
|
||||
* Everything that happens in the pipeline is written here: feed fetches,
|
||||
* AI calls, image downloads, post creation, scheduling and admin actions.
|
||||
*/
|
||||
final class Logger {
|
||||
|
||||
public static function info(string $message, string $context = ''): void {
|
||||
self::write('info', $message, $context);
|
||||
}
|
||||
|
||||
public static function warn(string $message, string $context = ''): void {
|
||||
self::write('warn', $message, $context);
|
||||
}
|
||||
|
||||
public static function error(string $message, string $context = ''): void {
|
||||
self::write('error', $message, $context);
|
||||
}
|
||||
|
||||
private static function write(string $level, string $message, string $context): void {
|
||||
// Never let logging break the pipeline.
|
||||
try {
|
||||
LogRepository::add($level, $message, $context);
|
||||
} catch (\Throwable $e) {
|
||||
if (function_exists('error_log')) {
|
||||
error_log('[Automatic AI Posts] log failure: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Logs;
|
||||
|
||||
use AAP\Core\Module;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Database-backed logging with a 30 day retention window.
|
||||
*/
|
||||
final class LogsModule extends Module {
|
||||
|
||||
public function id(): string {
|
||||
return 'logs';
|
||||
}
|
||||
|
||||
public function boot(): void {
|
||||
LogRepository::maybe_install();
|
||||
|
||||
// Retention: runs at most once a day.
|
||||
if (!get_transient('aap_logs_purged')) {
|
||||
LogRepository::purge();
|
||||
set_transient('aap_logs_purged', 1, DAY_IN_SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Pipeline;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Modules\Ai\AiService;
|
||||
use AAP\Modules\Content\Scraper;
|
||||
use AAP\Modules\Feeds\GnewsFetcher;
|
||||
use AAP\Modules\Feeds\RssFetcher;
|
||||
use AAP\Modules\Images\ImageService;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
use AAP\Modules\Posts\PostCreator;
|
||||
use AAP\Support\Languages;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Orchestrates one full run:
|
||||
* fetch -> AI selection -> (scrape -> rewrite -> category -> image -> post) x N
|
||||
*/
|
||||
final class Pipeline {
|
||||
|
||||
private AiService $ai;
|
||||
private RssFetcher $rss;
|
||||
private GnewsFetcher $gnews;
|
||||
private Scraper $scraper;
|
||||
private ImageService $images;
|
||||
private PostCreator $posts;
|
||||
|
||||
public function __construct() {
|
||||
$this->ai = new AiService();
|
||||
$this->rss = new RssFetcher();
|
||||
$this->gnews = new GnewsFetcher();
|
||||
$this->scraper = new Scraper();
|
||||
$this->images = new ImageService($this->ai);
|
||||
$this->posts = new PostCreator($this->images);
|
||||
}
|
||||
|
||||
public function run(int $author_id = 0): void {
|
||||
Logger::info('=== Pipeline started ===', 'pipeline');
|
||||
|
||||
if (!$this->ai->is_ready()) {
|
||||
Logger::error('AI provider is not configured. Pipeline aborted.', 'pipeline');
|
||||
return;
|
||||
}
|
||||
|
||||
$language = Languages::current_post();
|
||||
$min = max(1, Options::int('aap_min_posts'));
|
||||
$max = max($min, Options::int('aap_max_posts'));
|
||||
$post_status = (string) Options::get('aap_post_status', 'draft');
|
||||
|
||||
Logger::info(
|
||||
'Settings: provider=' . $this->ai->provider_label()
|
||||
. " | language={$language} | posts={$min}-{$max} | status={$post_status} | author={$author_id}",
|
||||
'pipeline'
|
||||
);
|
||||
|
||||
$articles = $this->collect_articles();
|
||||
|
||||
if (empty($articles)) {
|
||||
Logger::error('No articles collected from RSS/GNews. Pipeline aborted.', 'pipeline');
|
||||
return;
|
||||
}
|
||||
|
||||
$recent = $this->recent_titles();
|
||||
if ($recent) {
|
||||
Logger::info(count($recent) . ' recent post title(s) loaded to avoid duplicates.', 'pipeline');
|
||||
}
|
||||
|
||||
// Deterministic exclusion (models often ignore the negative profile).
|
||||
$blocked_terms = TopicFilter::terms((string) Options::get('aap_negative_profile', ''));
|
||||
|
||||
if ($blocked_terms) {
|
||||
$before = count($articles);
|
||||
$articles = TopicFilter::filter($articles, $blocked_terms);
|
||||
$removed = $before - count($articles);
|
||||
|
||||
if ($removed > 0) {
|
||||
Logger::info("{$removed} article(s) removed by the exclusion list.", 'pipeline');
|
||||
}
|
||||
}
|
||||
|
||||
Logger::info('Asking the AI to select the best articles...', 'pipeline');
|
||||
$selected = $this->ai->select_topics($articles, $min, $max, $recent);
|
||||
|
||||
if (empty($selected)) {
|
||||
Logger::error('AI selected no articles. Pipeline aborted.', 'pipeline');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($blocked_terms) {
|
||||
$before = count($selected);
|
||||
$selected = TopicFilter::filter($selected, $blocked_terms);
|
||||
|
||||
if (count($selected) < $before) {
|
||||
Logger::warn('AI selected excluded topic(s); removed by the exclusion list.', 'pipeline');
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($selected)) {
|
||||
Logger::error('No article left after applying the exclusion list. Pipeline aborted.', 'pipeline');
|
||||
return;
|
||||
}
|
||||
|
||||
Logger::info(count($selected) . ' article(s) selected by the AI.', 'pipeline');
|
||||
|
||||
$categories = $this->blog_categories();
|
||||
if ($categories) {
|
||||
Logger::info(count($categories) . ' blog category(ies) available for the AI.', 'pipeline');
|
||||
}
|
||||
|
||||
$total = count($selected);
|
||||
|
||||
foreach ($selected as $index => $article) {
|
||||
if (RunState::consume_abort()) {
|
||||
Logger::warn('=== Run aborted by the user ===', 'pipeline');
|
||||
break;
|
||||
}
|
||||
|
||||
RunState::touch();
|
||||
|
||||
$num = $index + 1;
|
||||
Logger::info("[{$num}/{$total}] Processing: \"{$article['title']}\"", 'pipeline');
|
||||
|
||||
$this->process_article($article, $num, $language, $post_status, $author_id, $categories);
|
||||
|
||||
wp_cache_flush();
|
||||
}
|
||||
|
||||
update_option('aap_last_run', current_time('mysql'));
|
||||
Logger::info('=== Pipeline finished ===', 'pipeline');
|
||||
}
|
||||
|
||||
/** @return array<int, array{title:string,content:string,link:string,source:string}> */
|
||||
private function collect_articles(): array {
|
||||
$feeds = array_filter(array_map('trim', explode("\n", (string) Options::get('aap_feeds', ''))));
|
||||
|
||||
$rss_articles = [];
|
||||
|
||||
if ($feeds) {
|
||||
Logger::info('Fetching ' . count($feeds) . ' RSS feed(s)...', 'pipeline');
|
||||
$rss_articles = $this->rss->fetch_all($feeds);
|
||||
} else {
|
||||
Logger::info('No RSS feed configured; skipping.', 'pipeline');
|
||||
}
|
||||
|
||||
$gnews_articles = $this->gnews->fetch_all();
|
||||
|
||||
Logger::info(
|
||||
count($rss_articles) . ' from RSS + ' . count($gnews_articles) . ' from GNews = '
|
||||
. (count($rss_articles) + count($gnews_articles)) . ' total.',
|
||||
'pipeline'
|
||||
);
|
||||
|
||||
return array_merge($rss_articles, $gnews_articles);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{title:string,content:string,link:string,source:string} $article
|
||||
* @param array<int,string> $categories
|
||||
*/
|
||||
private function process_article(
|
||||
array $article,
|
||||
int $num,
|
||||
string $language,
|
||||
string $post_status,
|
||||
int $author_id,
|
||||
array $categories
|
||||
): void {
|
||||
if (!empty($article['link'])) {
|
||||
Logger::info("[{$num}] Scraping full content: {$article['link']}", 'pipeline');
|
||||
$scraped = $this->scraper->scrape($article['link']);
|
||||
|
||||
if (!empty($scraped)) {
|
||||
Logger::info("[{$num}] Full content extracted (" . mb_strlen($scraped) . ' chars).', 'pipeline');
|
||||
$article['content'] = $scraped;
|
||||
} else {
|
||||
Logger::warn("[{$num}] Could not scrape the page; using the feed content.", 'pipeline');
|
||||
}
|
||||
}
|
||||
|
||||
Logger::info("[{$num}] Rewriting with AI...", 'pipeline');
|
||||
$rewritten = $this->ai->rewrite($article, $language);
|
||||
|
||||
if ($rewritten === null) {
|
||||
Logger::error("[{$num}] Rewrite failed; skipping this article.", 'pipeline');
|
||||
return;
|
||||
}
|
||||
|
||||
Logger::info("[{$num}] Rewritten: \"{$rewritten['title']}\"", 'pipeline');
|
||||
|
||||
$category_id = 0;
|
||||
if ($categories) {
|
||||
$category_id = $this->ai->choose_category($rewritten['title'], $rewritten['content'], $categories, $language);
|
||||
|
||||
if ($category_id > 0) {
|
||||
Logger::info("[{$num}] Category: {$categories[$category_id]} (ID {$category_id})", 'pipeline');
|
||||
} else {
|
||||
Logger::warn("[{$num}] AI did not choose a category; using the WordPress default.", 'pipeline');
|
||||
}
|
||||
}
|
||||
|
||||
Logger::info("[{$num}] Searching a cover image...", 'pipeline');
|
||||
$image = $this->images->resolve($rewritten['title'], $rewritten['content'], $language);
|
||||
|
||||
if (empty($image['urls'])) {
|
||||
Logger::warn("[{$num}] No cover image found; the post will be saved without one.", 'pipeline');
|
||||
} else {
|
||||
Logger::info("[{$num}] " . count($image['urls']) . ' image candidate(s) found.', 'pipeline');
|
||||
}
|
||||
|
||||
Logger::info("[{$num}] Creating the post (status: {$post_status})...", 'pipeline');
|
||||
|
||||
$post_id = $this->posts->create(
|
||||
$rewritten['title'],
|
||||
$rewritten['content'],
|
||||
$image['urls'],
|
||||
$image['meta'],
|
||||
$author_id,
|
||||
$category_id,
|
||||
$post_status
|
||||
);
|
||||
|
||||
if ($post_id > 0) {
|
||||
$label = $post_status === 'publish' ? 'Published' : 'Draft created';
|
||||
Logger::info("[{$num}] {$label} successfully (post ID {$post_id}).", 'pipeline');
|
||||
} else {
|
||||
Logger::error("[{$num}] Could not save the post.", 'pipeline');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private function recent_titles(): array {
|
||||
$posts = get_posts([
|
||||
'post_status' => ['publish', 'draft'],
|
||||
'posts_per_page' => 50,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
'fields' => 'all',
|
||||
]);
|
||||
|
||||
return array_map(static fn($post) => $post->post_title, $posts);
|
||||
}
|
||||
|
||||
/** @return array<int,string> id => name */
|
||||
private function blog_categories(): array {
|
||||
$terms = get_terms(['taxonomy' => 'category', 'hide_empty' => false]);
|
||||
|
||||
if (is_wp_error($terms) || empty($terms)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$default = (int) get_option('default_category', 1);
|
||||
$map = [];
|
||||
|
||||
foreach ($terms as $term) {
|
||||
if ((int) $term->term_id === $default) {
|
||||
continue;
|
||||
}
|
||||
$map[(int) $term->term_id] = $term->name;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Pipeline;
|
||||
|
||||
use AAP\Core\Module;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Runs the pipeline in a separate PHP process so the admin request returns
|
||||
* immediately and long runs are not killed by the web server timeout.
|
||||
*/
|
||||
final class PipelineModule extends Module {
|
||||
|
||||
public function id(): string {
|
||||
return 'pipeline';
|
||||
}
|
||||
|
||||
public function dependencies(): array {
|
||||
return ['logs', 'i18n', 'ai', 'feeds', 'content', 'images', 'posts'];
|
||||
}
|
||||
|
||||
public function boot(): void {
|
||||
add_action('aap_run_pipeline_cli', [$this, 'run_cli'], 10, 1);
|
||||
}
|
||||
|
||||
/** Entry point used by the CLI bootstrapper (bin/run-pipeline.php). */
|
||||
public function run_cli(int $author_id = 0): void {
|
||||
Logger::info('Background process started (PID ' . getmypid() . ').', 'pipeline');
|
||||
|
||||
RunState::start();
|
||||
|
||||
try {
|
||||
(new Pipeline())->run($author_id);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error(
|
||||
'Unhandled exception: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine(),
|
||||
'pipeline'
|
||||
);
|
||||
} finally {
|
||||
RunState::stop();
|
||||
update_option('aap_last_run', current_time('mysql'));
|
||||
Logger::info('=== Pipeline process finished ===', 'pipeline');
|
||||
}
|
||||
}
|
||||
|
||||
/** Spawns the background process. Returns false when it cannot start. */
|
||||
public function spawn(int $user_id): bool {
|
||||
if (!function_exists('shell_exec')) {
|
||||
Logger::error('shell_exec is disabled; cannot start the background process.', 'pipeline');
|
||||
return false;
|
||||
}
|
||||
|
||||
$php = $this->php_binary();
|
||||
if ($php === '') {
|
||||
Logger::error('PHP CLI binary not found; cannot start the background process.', 'pipeline');
|
||||
return false;
|
||||
}
|
||||
|
||||
$script = AAP_PLUGIN_DIR . 'bin/run-pipeline.php';
|
||||
$stderr = WP_CONTENT_DIR . '/uploads/automatic-ai-posts-stderr.log';
|
||||
|
||||
$cmd = escapeshellarg($php) . ' ' . escapeshellarg($script) . ' ' . (int) $user_id
|
||||
. ' >> ' . escapeshellarg($stderr) . ' 2>&1 & echo $!';
|
||||
|
||||
$pid = (int) trim((string) shell_exec($cmd));
|
||||
|
||||
if ($pid <= 0) {
|
||||
Logger::error('Could not start the background process (invalid PID).', 'pipeline');
|
||||
return false;
|
||||
}
|
||||
|
||||
RunState::start();
|
||||
update_option('aap_pipeline_pid', $pid);
|
||||
|
||||
Logger::info("Pipeline started in background (PID {$pid}, author {$user_id}).", 'pipeline');
|
||||
return true;
|
||||
}
|
||||
|
||||
private function php_binary(): string {
|
||||
$candidate = PHP_BINARY;
|
||||
|
||||
if ($candidate !== '' && is_executable($candidate) && $this->is_php($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
foreach (['/usr/local/bin/php', '/usr/bin/php', '/usr/bin/php8', '/usr/bin/php82', '/usr/bin/php83'] as $path) {
|
||||
if (is_executable($path) && $this->is_php($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
$found = trim((string) shell_exec('which php 2>/dev/null'));
|
||||
|
||||
return ($found !== '' && is_executable($found)) ? $found : '';
|
||||
}
|
||||
|
||||
private function is_php(string $path): bool {
|
||||
$out = shell_exec(escapeshellarg($path) . ' -r "echo \'ok\';" 2>/dev/null');
|
||||
return trim((string) $out) === 'ok';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Pipeline;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Shared "is a run happening?" state (transients + option), used by the
|
||||
* pipeline, the admin AJAX handlers and the scheduler.
|
||||
*/
|
||||
final class RunState {
|
||||
|
||||
/** 20 minutes, in seconds. */
|
||||
private const TTL = 1200;
|
||||
|
||||
public static function is_running(): bool {
|
||||
return (bool) get_transient('aap_is_processing');
|
||||
}
|
||||
|
||||
public static function started_at(): int {
|
||||
return (int) get_option('aap_processing_started_at', 0);
|
||||
}
|
||||
|
||||
/** A run that never finished (process died) must not block forever. */
|
||||
public static function is_stale(): bool {
|
||||
$started = self::started_at();
|
||||
return $started > 0 && (time() - $started) > 25 * MINUTE_IN_SECONDS;
|
||||
}
|
||||
|
||||
public static function start(): void {
|
||||
// A previous abort must not cancel the new run.
|
||||
delete_transient('aap_abort_requested');
|
||||
|
||||
set_transient('aap_is_processing', true, self::TTL);
|
||||
update_option('aap_processing_started_at', time(), false);
|
||||
}
|
||||
|
||||
public static function touch(): void {
|
||||
set_transient('aap_is_processing', true, self::TTL);
|
||||
}
|
||||
|
||||
public static function stop(): void {
|
||||
delete_transient('aap_is_processing');
|
||||
delete_transient('aap_abort_requested');
|
||||
delete_option('aap_processing_started_at');
|
||||
delete_option('aap_pipeline_pid');
|
||||
}
|
||||
|
||||
public static function request_abort(): void {
|
||||
set_transient('aap_abort_requested', true, self::TTL);
|
||||
}
|
||||
|
||||
/** Returns true once per abort request (consumes the flag). */
|
||||
public static function consume_abort(): bool {
|
||||
if (get_transient('aap_abort_requested')) {
|
||||
delete_transient('aap_abort_requested');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Pipeline;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Deterministic exclusion filter built from the "negative profile" text.
|
||||
*
|
||||
* Models do not always honour the exclusion list, so articles matching the
|
||||
* configured topics are removed before and after the AI selection.
|
||||
*/
|
||||
final class TopicFilter {
|
||||
|
||||
private const STOPWORDS = [
|
||||
'nunca', 'nao', 'não', 'evite', 'evitar', 'selecione', 'selecionar',
|
||||
'nunca selecione', 'não selecione', 'nao selecione',
|
||||
'never', 'avoid', 'select', 'do not', 'not', 'the', 'and', 'or',
|
||||
'ou', 'e', 'de', 'da', 'do', 'das', 'dos', 'com', 'para', 'por',
|
||||
'sobre', 'temas', 'tema', 'conteudo', 'conteúdo', 'artigos', 'artigo',
|
||||
'noticias', 'notícias', 'qualquer', 'qualquer coisa',
|
||||
];
|
||||
|
||||
/** Lowercase + accent-free, so "política" also matches "politica" in URLs. */
|
||||
private static function normalize(string $text): string {
|
||||
$text = function_exists('remove_accents') ? remove_accents($text) : $text;
|
||||
return mb_strtolower($text);
|
||||
}
|
||||
|
||||
/** @return string[] lowercase terms */
|
||||
public static function terms(string $negative_profile): array {
|
||||
$text = self::normalize(trim($negative_profile));
|
||||
|
||||
if ($text === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Remove instruction words so only the topics remain.
|
||||
$text = preg_replace('/\b(nunca|não|nao|evite|evitar|selecione|selecionar|never|avoid|select)\b/u', ' ', $text);
|
||||
|
||||
$parts = preg_split('/[,;\.\n\/]+|\s+ou\s+|\s+or\s+/u', (string) $text) ?: [];
|
||||
|
||||
$terms = [];
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part, " \t\n\r.:;-\"'()[]");
|
||||
|
||||
if ($part === '' || mb_strlen($part) < 4) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$part = self::normalize($part);
|
||||
|
||||
if (in_array($part, self::STOPWORDS, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$terms[$part] = true;
|
||||
}
|
||||
|
||||
return array_keys($terms);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{title:string,content:string,link:string,source:string} $article
|
||||
* @param string[] $terms
|
||||
*/
|
||||
public static function is_blocked(array $article, array $terms): bool {
|
||||
if (empty($terms)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$haystack = self::normalize($article['title'] . ' ' . $article['link']);
|
||||
|
||||
foreach ($terms as $term) {
|
||||
if ($term !== '' && mb_strpos($haystack, $term) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array> $articles
|
||||
* @param string[] $terms
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public static function filter(array $articles, array $terms): array {
|
||||
if (empty($terms)) {
|
||||
return $articles;
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$articles,
|
||||
static fn(array $article): bool => !self::is_blocked($article, $terms)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Posts;
|
||||
|
||||
use AAP\Modules\Images\ImageService;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Creates the WordPress post and attaches the cover image.
|
||||
*/
|
||||
final class PostCreator {
|
||||
|
||||
private ImageService $images;
|
||||
|
||||
public function __construct(?ImageService $images = null) {
|
||||
$this->images = $images ?? new ImageService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $image_urls
|
||||
* @param array{alt:string,caption:string,description:string}|null $image_meta
|
||||
*/
|
||||
public function create(
|
||||
string $title,
|
||||
string $content,
|
||||
array $image_urls = [],
|
||||
?array $image_meta = null,
|
||||
int $author_id = 0,
|
||||
int $category_id = 0,
|
||||
string $post_status = 'draft'
|
||||
): int {
|
||||
$post_data = [
|
||||
'post_title' => $title,
|
||||
'post_content' => $content,
|
||||
'post_status' => in_array($post_status, ['draft', 'publish'], true) ? $post_status : 'draft',
|
||||
'post_author' => $author_id > 0 ? $author_id : $this->admin_id(),
|
||||
];
|
||||
|
||||
if ($category_id > 0) {
|
||||
$post_data['post_category'] = [$category_id];
|
||||
}
|
||||
|
||||
$post_id = wp_insert_post($post_data, true);
|
||||
|
||||
if (is_wp_error($post_id)) {
|
||||
Logger::error('Could not create the post: ' . $post_id->get_error_message(), 'posts');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!empty($image_urls)) {
|
||||
$attachment_id = $this->images->attach(
|
||||
$image_urls,
|
||||
(int) $post_id,
|
||||
$title,
|
||||
$image_meta ?? ['alt' => $title, 'caption' => '', 'description' => '']
|
||||
);
|
||||
|
||||
if ($attachment_id > 0) {
|
||||
set_post_thumbnail((int) $post_id, $attachment_id);
|
||||
}
|
||||
}
|
||||
|
||||
return (int) $post_id;
|
||||
}
|
||||
|
||||
private function admin_id(): int {
|
||||
$admins = get_users(['role' => 'administrator', 'number' => 1, 'fields' => 'ID']);
|
||||
return !empty($admins) ? (int) $admins[0] : 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Posts;
|
||||
|
||||
use AAP\Core\Module;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* WordPress post creation and featured image assignment.
|
||||
*/
|
||||
final class PostsModule extends Module {
|
||||
|
||||
public function id(): string {
|
||||
return 'posts';
|
||||
}
|
||||
|
||||
public function dependencies(): array {
|
||||
return ['logs', 'images'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Schedule;
|
||||
|
||||
use AAP\Core\Module;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Automatic generation scheduling.
|
||||
*/
|
||||
final class ScheduleModule extends Module {
|
||||
|
||||
private ?Scheduler $scheduler = null;
|
||||
|
||||
public function id(): string {
|
||||
return 'schedule';
|
||||
}
|
||||
|
||||
public function dependencies(): array {
|
||||
return ['logs', 'pipeline'];
|
||||
}
|
||||
|
||||
public function boot(): void {
|
||||
$this->scheduler = new Scheduler();
|
||||
|
||||
add_action('shutdown', [$this, 'on_shutdown']);
|
||||
add_action('aap_cron_tick', [$this->scheduler, 'tick']);
|
||||
add_action('update_option_aap_auto_interval_value', [$this->scheduler, 'reschedule']);
|
||||
add_action('update_option_aap_auto_interval_unit', [$this->scheduler, 'reschedule']);
|
||||
}
|
||||
|
||||
/** Runs on every web request, after the response was sent. */
|
||||
public function on_shutdown(): void {
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
$this->scheduler?->tick();
|
||||
}
|
||||
}
|
||||
|
||||
public function scheduler(): ?Scheduler {
|
||||
return $this->scheduler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
namespace AAP\Modules\Schedule;
|
||||
|
||||
use AAP\Core\Options;
|
||||
use AAP\Core\Registry;
|
||||
use AAP\Modules\Logs\Logger;
|
||||
use AAP\Modules\Pipeline\PipelineModule;
|
||||
use AAP\Modules\Pipeline\RunState;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Own scheduler (no WP-Cron): stores the next run timestamp and spawns the
|
||||
* pipeline when the time comes.
|
||||
*/
|
||||
final class Scheduler {
|
||||
|
||||
public static function interval_seconds(): int {
|
||||
$value = max(1, Options::int('aap_auto_interval_value'));
|
||||
$unit = (string) Options::get('aap_auto_interval_unit', 'hours');
|
||||
|
||||
return match ($unit) {
|
||||
'minutes' => $value * MINUTE_IN_SECONDS,
|
||||
'days' => $value * DAY_IN_SECONDS,
|
||||
default => $value * HOUR_IN_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
public function start(string $mode, int $user_id, int $first_run_at = 0): void {
|
||||
update_option('aap_auto_enabled', true);
|
||||
update_option('aap_auto_user_id', $user_id);
|
||||
|
||||
$label = Options::int('aap_auto_interval_value') . ' ' . Options::get('aap_auto_interval_unit', 'hours');
|
||||
|
||||
if ($mode === 'now') {
|
||||
update_option('aap_next_auto_run', time() + self::interval_seconds());
|
||||
Logger::info("Automatic generation enabled (user {$user_id}, interval {$label}). Running now.", 'schedule');
|
||||
$this->spawn($user_id);
|
||||
return;
|
||||
}
|
||||
|
||||
$first = ($first_run_at > 0 && $first_run_at > time())
|
||||
? $first_run_at
|
||||
: time() + self::interval_seconds();
|
||||
|
||||
update_option('aap_next_auto_run', $first);
|
||||
Logger::info(
|
||||
"Automatic generation enabled (user {$user_id}, interval {$label}). First run: " . wp_date('d/m/Y H:i', $first) . '.',
|
||||
'schedule'
|
||||
);
|
||||
}
|
||||
|
||||
public function stop(): void {
|
||||
update_option('aap_auto_enabled', false);
|
||||
delete_option('aap_next_auto_run');
|
||||
delete_transient('aap_auto_spawn_lock');
|
||||
wp_clear_scheduled_hook('aap_auto_run');
|
||||
|
||||
Logger::info('Automatic generation paused.', 'schedule');
|
||||
}
|
||||
|
||||
public function reschedule(): void {
|
||||
if (!Options::bool('aap_auto_enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
update_option('aap_next_auto_run', time() + self::interval_seconds());
|
||||
Logger::info('Interval changed; next run rescheduled.', 'schedule');
|
||||
}
|
||||
|
||||
/** Called on every web request (shutdown) and by the system cron. */
|
||||
public function tick(): void {
|
||||
if (!Options::bool('aap_auto_enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (RunState::is_running() && !RunState::is_stale()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$next = (int) get_option('aap_next_auto_run', 0);
|
||||
if ($next === 0 || time() < $next) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Atomic-ish lock: avoid double spawn in concurrent requests.
|
||||
if (get_transient('aap_auto_spawn_lock')) {
|
||||
return;
|
||||
}
|
||||
set_transient('aap_auto_spawn_lock', 1, 60);
|
||||
|
||||
$next_ts = time() + self::interval_seconds();
|
||||
update_option('aap_next_auto_run', $next_ts);
|
||||
|
||||
$author = (int) get_option('aap_auto_user_id', 0);
|
||||
Logger::info('Automatic run triggered by the scheduler. Next: ' . wp_date('d/m/Y H:i', $next_ts) . '.', 'schedule');
|
||||
|
||||
$this->spawn($author);
|
||||
}
|
||||
|
||||
private function spawn(int $user_id): bool {
|
||||
$pipeline = Registry::get('pipeline');
|
||||
|
||||
if (!$pipeline instanceof PipelineModule) {
|
||||
Logger::error('Pipeline module unavailable; cannot start a run.', 'schedule');
|
||||
return false;
|
||||
}
|
||||
|
||||
return $pipeline->spawn($user_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
namespace AAP\Support;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Inline SVG icons used by the admin UI (stroke style, 24x24).
|
||||
*/
|
||||
final class Icons {
|
||||
|
||||
public static function svg(string $name, string $class = ''): string {
|
||||
$paths = self::paths()[$name] ?? self::paths()['info'];
|
||||
$attrs = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"'
|
||||
. ' stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"';
|
||||
if ($class !== '') {
|
||||
$attrs .= ' class="' . esc_attr($class) . '"';
|
||||
}
|
||||
return '<svg ' . $attrs . '>' . $paths . '</svg>';
|
||||
}
|
||||
|
||||
public static function logo(string $class = ''): string {
|
||||
$class_attr = $class !== '' ? ' class="' . esc_attr($class) . '"' : '';
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"'
|
||||
. ' stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"' . $class_attr . '>'
|
||||
. '<rect x="3.5" y="8" width="12" height="9.5" rx="3"/>'
|
||||
. '<path d="M7.6 17.5v2.2M11.4 17.5v2.2"/>'
|
||||
. '<path d="M6.6 12.2h.01M9.9 12.2h.01"/>'
|
||||
. '<path d="M14.5 6.5V6"/>'
|
||||
. '<circle cx="17.6" cy="4.6" r="1.05"/>'
|
||||
. '<circle cx="17.6" cy="9.2" r="1.05"/>'
|
||||
. '<path d="M15.6 9.2h1.2M18.4 9.2h1.2"/>'
|
||||
. '</svg>';
|
||||
}
|
||||
|
||||
private static function paths(): array {
|
||||
return [
|
||||
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'play' => '<polygon points="7 4.5 19 12 7 19.5 7 4.5"/>',
|
||||
'list' => '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||
'help' => '<circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.5 2.5 0 0 1 4.85.85c0 1.65-2.45 2.45-2.45 2.45"/><line x1="12" y1="16.6" x2="12.01" y2="16.6"/>',
|
||||
'chevron-down' => '<polyline points="6 9 12 15 18 9"/>',
|
||||
'caret' => '<polyline points="6 9 12 15 18 9"/>',
|
||||
'rss' => '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1.2"/>',
|
||||
'gnews' => '<path d="M4 6.5A2.5 2.5 0 0 1 6.5 4H19a1 1 0 0 1 1 1v13.5A1.5 1.5 0 0 1 18.5 20h-12A2.5 2.5 0 0 1 4 17.5z"/><line x1="7.5" y1="8.5" x2="14.5" y2="8.5"/><line x1="7.5" y1="12" x2="14.5" y2="12"/><line x1="7.5" y1="15.5" x2="11" y2="15.5"/>',
|
||||
'ai' => '<rect x="5" y="7" width="14" height="12" rx="3"/><circle cx="9.5" cy="13" r="1"/><circle cx="14.5" cy="13" r="1"/><line x1="12" y1="3.5" x2="12" y2="7"/><circle cx="12" cy="3" r="1"/><line x1="2.5" y1="11" x2="5" y2="11"/><line x1="19" y1="11" x2="21.5" y2="11"/>',
|
||||
'info' => '<circle cx="12" cy="12" r="9"/><line x1="12" y1="11" x2="12" y2="16.5"/><line x1="12" y1="7.8" x2="12.01" y2="7.8"/>',
|
||||
'check' => '<polyline points="20 6.5 9.5 17 4 11.5"/>',
|
||||
'trash' => '<polyline points="3.5 6.5 20.5 6.5"/><path d="M18.5 6.5V19a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2V6.5"/><path d="M9 6.5V4.5a1.5 1.5 0 0 1 1.5-1.5h3A1.5 1.5 0 0 1 15 4.5v2"/>',
|
||||
'clock' => '<circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/>',
|
||||
'image' => '<rect x="3" y="4" width="18" height="16" rx="2.5"/><circle cx="9" cy="9.5" r="1.6"/><path d="m3.5 17.5 4.7-4.7a2 2 0 0 1 2.8 0l5.5 5.5"/><path d="m14.5 14.5 1.8-1.8a2 2 0 0 1 2.8 0l1.4 1.4"/>',
|
||||
'save' => '<path d="M5 3.5h11.5L20.5 7v13a.5.5 0 0 1-.5.5H4a.5.5 0 0 1-.5-.5V4a.5.5 0 0 1 .5-.5z"/><polyline points="8 3.5 8 9.5 16 9.5 16 3.5"/><rect x="7.5" y="13.5" width="9" height="7"/>',
|
||||
'refresh' => '<path d="M20.5 12a8.5 8.5 0 1 1-2.6-6.1"/><polyline points="20.5 4 20.5 9.5 15 9.5"/>',
|
||||
'square' => '<rect x="6" y="6" width="12" height="12" rx="2"/>',
|
||||
'external' => '<path d="M14 4.5h5.5V10"/><path d="M19 5 10.5 13.5"/><path d="M18.5 14.5v4a1.5 1.5 0 0 1-1.5 1.5H5.5A1.5 1.5 0 0 1 4 18.5V7a1.5 1.5 0 0 1 1.5-1.5h4"/>',
|
||||
'heart' => '<path d="M12 20s-7.5-4.6-7.5-9.5A4.3 4.3 0 0 1 12 7.6a4.3 4.3 0 0 1 7.5 2.9C19.5 15.4 12 20 12 20z"/>',
|
||||
'shield' => '<path d="M12 3.5 19.5 6v6c0 4.4-3.2 7.6-7.5 8.5C7.7 19.6 4.5 16.4 4.5 12V6z"/><polyline points="8.8 11.8 11.2 14.2 15.4 10"/>',
|
||||
'key' => '<circle cx="8" cy="15" r="4"/><path d="m11 12 8-8 2 2-2 2 2 2-3 3-2-2-2 2"/>',
|
||||
'globe' => '<circle cx="12" cy="12" r="9"/><path d="M3.5 12h17"/><path d="M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"/>',
|
||||
'database' => '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6"/><path d="M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>',
|
||||
'star' => '<polygon points="12 3.5 14.6 8.8 20.5 9.6 16.2 13.7 17.3 19.5 12 16.7 6.7 19.5 7.8 13.7 3.5 9.6 9.4 8.8"/>',
|
||||
'code' => '<polyline points="9 7 4 12 9 17"/><polyline points="15 7 20 12 15 17"/>',
|
||||
'pix' => '<path d="M12 3.5 20.5 12 12 20.5 3.5 12z"/><path d="M8.5 12h7"/>',
|
||||
'user' => '<circle cx="12" cy="8" r="3.5"/><path d="M4.5 20c0-3.6 3.4-6 7.5-6s7.5 2.4 7.5 6"/>',
|
||||
'book' => '<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H19v15.5H6.5A2.5 2.5 0 0 0 4 21z"/><path d="M4 18.5A2.5 2.5 0 0 1 6.5 16H19"/>',
|
||||
'bolt' => '<polygon points="13 3 5 13 11 13 10 21 19 10 12.5 10 13 3"/>',
|
||||
'mail' => '<rect x="3" y="5" width="18" height="14" rx="2.5"/><polyline points="4 7 12 13 20 7"/>',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
namespace AAP\Support;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Supported languages.
|
||||
*
|
||||
* - UI languages (app): files in languages/{code}.php
|
||||
* - Post languages: used to instruct the AI and to query GNews
|
||||
*/
|
||||
final class Languages {
|
||||
|
||||
/** code => [native label, english name (for prompts), gnews lang, gnews country] */
|
||||
public const POST = [
|
||||
'pt-BR' => ['Português (Brasil)', 'Brazilian Portuguese', 'pt', 'br'],
|
||||
'en-US' => ['English (US)', 'English', 'en', 'us'],
|
||||
'es-ES' => ['Español', 'Spanish', 'es', 'es'],
|
||||
'fr-FR' => ['Français', 'French', 'fr', 'fr'],
|
||||
'de-DE' => ['Deutsch', 'German', 'de', 'de'],
|
||||
'it-IT' => ['Italiano', 'Italian', 'it', 'it'],
|
||||
'ja-JP' => ['日本語', 'Japanese', 'ja', 'jp'],
|
||||
'zh-CN' => ['中文', 'Chinese (Simplified)', 'zh', 'cn'],
|
||||
'ru-RU' => ['Русский', 'Russian', 'ru', 'ru'],
|
||||
'ar-SA' => ['العربية', 'Arabic', 'ar', 'sa'],
|
||||
'hi-IN' => ['हिन्दी', 'Hindi', 'hi', 'in'],
|
||||
];
|
||||
|
||||
/** UI languages available in languages/ */
|
||||
public const APP = [
|
||||
'pt_BR' => 'Português (Brasil)',
|
||||
'en' => 'English',
|
||||
'es' => 'Español',
|
||||
'fr' => 'Français',
|
||||
'de' => 'Deutsch',
|
||||
'it' => 'Italiano',
|
||||
];
|
||||
|
||||
/** @return array<string,string> code => native label */
|
||||
public static function post_options(): array {
|
||||
$out = [];
|
||||
foreach (self::POST as $code => $data) {
|
||||
$out[$code] = $data[0];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** English name used inside prompts (the AI must answer in this language). */
|
||||
public static function prompt_name(string $code): string {
|
||||
return self::POST[$code][1] ?? 'English';
|
||||
}
|
||||
|
||||
/** GNews lang/country pair. */
|
||||
public static function gnews(string $code): array {
|
||||
$data = self::POST[$code] ?? self::POST['pt-BR'];
|
||||
return [$data[2], $data[3]];
|
||||
}
|
||||
|
||||
public static function current_post(): string {
|
||||
$code = (string) \AAP\Core\Options::get('aap_post_language', 'pt-BR');
|
||||
return isset(self::POST[$code]) ? $code : 'pt-BR';
|
||||
}
|
||||
|
||||
public static function current_app(): string {
|
||||
$code = (string) \AAP\Core\Options::get('aap_app_language', 'pt_BR');
|
||||
return isset(self::APP[$code]) ? $code : 'pt_BR';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user