'POST', 'callback' => function () { return new WP_REST_Response(['ok' => true], 200); }, 'permission_callback' => 'ailoquence_verify_request', ]); // Público a propósito: permite comprobar que el plugin está instalado y activo sin credenciales. // No expone la versión de WordPress ni la URL del sitio (sí lo hacía hasta 0.1.x). register_rest_route('ailoquence/v1', '/status', [ 'methods' => 'GET', 'callback' => function () { $opt = ailoquence_get_options(); return new WP_REST_Response([ 'ok' => true, 'enabled' => (bool) $opt['enabled'], 'plugin' => AILOQUENCE_CONNECTOR_VERSION, ], 200); }, 'permission_callback' => '__return_true', ]); register_rest_route('ailoquence/v1', '/store-status', [ 'methods' => 'GET', 'callback' => function () { $store_opt = ailoquence_get_store_options(); return new WP_REST_Response([ 'ok' => true, 'site_url' => home_url(), 'woocommerceActive' => ailoquence_is_woocommerce_active(), 'storeConnected' => !empty($store_opt['store_connected']), 'storeConnectedAt' => (string) $store_opt['store_connected_at'], 'storeLastError' => (string) $store_opt['store_last_error'], 'connectionId' => (string) $store_opt['connection_id'], ], 200); }, 'permission_callback' => 'ailoquence_verify_request', ]); register_rest_route('ailoquence/v1', '/publish', [ 'methods' => 'POST', 'callback' => 'ailoquence_publish_handler', 'permission_callback' => 'ailoquence_verify_request', ]); register_rest_route('ailoquence/v1', '/upload-media', [ 'methods' => 'POST', 'callback' => 'ailoquence_upload_media_handler', 'permission_callback' => 'ailoquence_verify_request', ]); register_rest_route('ailoquence/v1', '/import-post', [ 'methods' => 'POST', 'callback' => 'ailoquence_import_post_handler', 'permission_callback' => 'ailoquence_verify_request', ]); register_rest_route('ailoquence/v1', '/import-posts', [ 'methods' => 'POST', 'callback' => 'ailoquence_import_posts_handler', 'permission_callback' => 'ailoquence_verify_request', ]); }); function ailoquence_import_post_handler(WP_REST_Request $req) { $params = $req->get_json_params(); $post_id = isset($params['postId']) ? intval($params['postId']) : 0; if ($post_id <= 0) { return new WP_REST_Response(['ok' => false, 'error' => 'INVALID_POST_ID'], 400); } $post = get_post($post_id); if (!$post || $post->post_type !== 'post') { return new WP_REST_Response(['ok' => false, 'error' => 'NOT_FOUND'], 404); } $featured_image_url = get_the_post_thumbnail_url($post->ID, 'full'); return new WP_REST_Response([ 'id' => (int) $post->ID, 'status' => (string) $post->post_status, 'date' => (string) get_post_time('c', false, $post), 'link' => (string) get_permalink($post), 'featuredImageUrl' => $featured_image_url ? (string) $featured_image_url : '', 'title' => ['rendered' => (string) get_the_title($post)], // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core filter. 'content' => ['rendered' => (string) apply_filters('the_content', $post->post_content)], ], 200); } function ailoquence_import_posts_handler(WP_REST_Request $req) { $params = $req->get_json_params(); $page = isset($params['page']) ? max(1, intval($params['page'])) : 1; $perPage = isset($params['perPage']) ? min(100, max(1, intval($params['perPage']))) : 20; // Sin 'fields' => 'ids': se necesita post_date para que Ailoquence pueda descartar posts // antiguos ANTES de pedir el post completo por /import-post (esa sí ejecuta // apply_filters('the_content', ...), la parte cara) — ver WordpressImportPostListener en el // backend. Sigue siendo UNA sola consulta SQL, WP_Query no hace N+1 por post aquí. $q = new WP_Query([ 'post_type' => 'post', 'post_status' => 'any', 'posts_per_page' => $perPage, 'paged' => $page, 'orderby' => 'date', 'order' => 'DESC', ]); // Mismo formato ('c' = ISO-8601 con offset del sitio, $gmt=false) que /import-post — el parser // del backend (parseWpDate()) ya lo soporta. $posts = array_map(function ($p) { return [ 'id' => (int) $p->ID, 'date' => get_post_time('c', false, $p), ]; }, $q->posts); return new WP_REST_Response([ 'ok' => true, 'page' => $page, 'perPage' => $perPage, 'total' => (int) $q->found_posts, 'totalPages' => (int) $q->max_num_pages, 'posts' => $posts, ], 200); } function ailoquence_find_post_by_external_id($externalId) { // _aio_external_id: posts publicados con 0.1.x. Sin mirarla, editar desde Ailoquence un post // publicado antes de actualizar el plugin crearía un duplicado en vez de actualizarlo. $ids = get_posts([ 'post_type' => 'post', 'post_status' => 'any', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- lookup by external id, one post. 'meta_query' => [ 'relation' => 'OR', ['key' => '_ailoquence_external_id', 'value' => $externalId], ['key' => '_aio_external_id', 'value' => $externalId], ], ]); return !empty($ids) ? (int) $ids[0] : 0; } function ailoquence_publish_handler(WP_REST_Request $request) { $data = json_decode($request->get_body(), true); if (!is_array($data)) { return new WP_Error('ailoquence_bad_json', 'Invalid JSON', ['status' => 400]); } $externalId = sanitize_text_field((string) ($data['externalId'] ?? '')); $title = (string) ($data['title'] ?? ''); $contentHtml = (string) ($data['contentHtml'] ?? ''); if ($externalId === '' || $title === '' || $contentHtml === '') { return new WP_Error('ailoquence_missing_fields', 'externalId, title, contentHtml required', ['status' => 400]); } $slug = sanitize_title((string) ($data['slug'] ?? '')); // Sin usuario autenticado (la petición llega firmada por HMAC, no con una sesión de WordPress), // así que wp_insert_post aplica wp_kses_post al contenido igual que para un autor sin // 'unfiltered_html'. $postarr = [ 'post_type' => 'post', 'post_title' => $title, 'post_content' => $contentHtml, 'post_excerpt' => (string) ($data['excerpt'] ?? ''), 'post_name' => $slug !== '' ? $slug : sanitize_title($title), ]; $allowed_status = ['publish', 'draft', 'pending', 'private', 'future']; $status = (string) ($data['status'] ?? 'publish'); if (!in_array($status, $allowed_status, true)) $status = 'publish'; $publishDate = (string) ($data['publishDate'] ?? ''); $ts = $publishDate !== '' ? strtotime($publishDate) : false; if ($ts) { // Ailoquence envía $publishDate en UTC con sufijo 'Z' (ISO-8601, ej. "2026-08-14T16:00:00Z") // — strtotime() respeta ese sufijo sea cual sea la zona horaria por defecto del servidor PHP, // así que $ts es siempre el instante UTC correcto, no depende de configuración del hosting. // post_date debe ir en hora LOCAL DEL SITIO (así lo trata wp_insert_post/wp_update_post): // get_date_from_gmt() convierte según Ajustes > General, y post_date_gmt se fija explícitamente // para no depender de la conversión inversa implícita. $postarr['post_date_gmt'] = gmdate('Y-m-d H:i:s', $ts); $postarr['post_date'] = get_date_from_gmt($postarr['post_date_gmt']); // time() es un timestamp UTC real, comparable con $ts — current_time('timestamp') no lo es // (va desplazado por el offset del sitio) y desplazaría la decisión future/publish. $postarr['post_status'] = $ts > time() ? 'future' : 'publish'; } else { $postarr['post_status'] = $status; } $existing_id = ailoquence_find_post_by_external_id($externalId); // wp_insert_post/wp_update_post hacen wp_unslash() de lo que reciben (esperan datos "slashed", // como vienen de $_POST) — sin wp_slash() se perderían las barras invertidas del contenido. if ($existing_id) { $postarr['ID'] = $existing_id; $post_id = wp_update_post(wp_slash($postarr), true); } else { // Solo en entradas nuevas: al re-publicar una existente se respeta el autor que tenga, por si // se cambió a mano en WordPress. $postarr['post_author'] = ailoquence_resolve_author_id(); $post_id = wp_insert_post(wp_slash($postarr), true); } if (is_wp_error($post_id)) return $post_id; update_post_meta($post_id, '_ailoquence_external_id', wp_slash($externalId)); delete_post_meta($post_id, '_aio_external_id'); $seo_plugins = ailoquence_apply_seo($post_id, is_array($data['meta'] ?? null) ? $data['meta'] : []); $categories = $data['categories'] ?? []; if (is_array($categories) && !empty($categories)) { $catIds = []; foreach ($categories as $catName) { $catName = sanitize_text_field((string) $catName); if ($catName === '') continue; $term = term_exists($catName, 'category'); if (!$term) $term = wp_insert_term($catName, 'category'); if (!is_wp_error($term) && isset($term['term_id'])) $catIds[] = (int) $term['term_id']; } if (!empty($catIds)) wp_set_post_terms($post_id, $catIds, 'category', false); } $tags = $data['tags'] ?? []; if (is_array($tags) && !empty($tags)) { $tagNames = array_values(array_filter(array_map(function ($t) { return sanitize_text_field((string) $t); }, $tags), 'strlen')); if (!empty($tagNames)) wp_set_post_terms($post_id, $tagNames, 'post_tag', false); } $featuredUrl = esc_url_raw((string) ($data['featuredImageUrl'] ?? '')); if ($featuredUrl !== '') { $res = ailoquence_set_featured_image_from_url($post_id, $featuredUrl); if (is_wp_error($res)) { return new WP_REST_Response([ 'ok' => true, 'postId' => $post_id, 'url' => get_permalink($post_id), 'warning' => $res->get_error_message(), 'seoPlugins' => $seo_plugins, ], 200); } } // seoPlugins: en qué plugin SEO se escribieron los metadatos (vacío = ninguno instalado, el // conector los imprime él mismo). Campo nuevo en 0.2.0, solo diagnóstico — el backend lo ignora. return new WP_REST_Response([ 'ok' => true, 'postId' => $post_id, 'url' => get_permalink($post_id), 'seoPlugins' => $seo_plugins, ], 200); } /** * Recibe la imagen como cuerpo binario crudo (no multipart) — el nombre llega en la cabecera * X-Ailoquence-Filename. Solo acepta imágenes: se comprueba la extensión antes de escribir y el * contenido real (wp_get_image_mime) después, por si la extensión no corresponde al fichero. */ function ailoquence_upload_media_handler(WP_REST_Request $request) { $raw_body = $request->get_body(); if (empty($raw_body)) { return new WP_Error('ailoquence_empty_image', 'Empty image body', ['status' => 400]); } $filename = $request->get_header('x-ailoquence-filename'); $filename = sanitize_file_name($filename ? $filename : 'ailoquence-image'); $content_type = sanitize_text_field((string) $request->get_header('content-type')); require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; if (!preg_match('/\.[a-z0-9]+$/i', $filename)) { if (strpos($content_type, 'image/jpeg') !== false) $filename .= '.jpg'; elseif (strpos($content_type, 'image/png') !== false) $filename .= '.png'; elseif (strpos($content_type, 'image/webp') !== false) $filename .= '.webp'; elseif (strpos($content_type, 'image/gif') !== false) $filename .= '.gif'; } $filetype = wp_check_filetype($filename); if (empty($filetype['type']) || strpos($filetype['type'], 'image/') !== 0) { return new WP_Error('ailoquence_not_an_image', 'Only image files are accepted', ['status' => 400]); } $upload = wp_upload_bits($filename, null, $raw_body); if (!empty($upload['error'])) { return new WP_Error('ailoquence_upload_failed', $upload['error'], ['status' => 400]); } $file_path = $upload['file']; if (!wp_get_image_mime($file_path)) { wp_delete_file($file_path); return new WP_Error('ailoquence_not_an_image', 'Only image files are accepted', ['status' => 400]); } $attach_id = wp_insert_attachment([ 'post_mime_type' => $filetype['type'], 'post_title' => preg_replace('/\.[^.]+$/', '', basename($file_path)), 'post_content' => '', 'post_status' => 'inherit', ], $file_path); if (is_wp_error($attach_id)) { return $attach_id; } wp_update_attachment_metadata($attach_id, wp_generate_attachment_metadata($attach_id, $file_path)); return new WP_REST_Response([ 'ok' => true, 'attachmentId' => $attach_id, 'sourceUrl' => $upload['url'], ], 200); }