post id ], so a second click opens the same one. */
define( 'GENSCORE_DRAFTS_OPTION', 'genscore_drafts' );
/** Headings in one draft, at most. */
define( 'GENSCORE_DRAFT_QUESTIONS', 8 );
/** Rivals in a comparison with the tracked rivals, at most. */
define( 'GENSCORE_DRAFT_RIVALS', 3 );
/** What a cell left for the person says. The editor box counts them. */
define( 'GENSCORE_PENDING', '[Pendiente' );
/**
* The plan as GenScore sent it.
*
* @return array{plan: array[], project: array}|null
*/
function genscore_plan_data() {
$data = genscore_account_data( false );
if ( ! is_array( $data ) || 'connected' !== $data['state'] || empty( $data['project']['plan'] ) || ! is_array( $data['project']['plan'] ) ) {
return null;
}
return array( 'plan' => array_values( $data['project']['plan'] ), 'project' => $data['project'] );
}
/**
* Distinct clean strings of a list.
*
* @param mixed $list List.
* @param int $max At most.
* @return string[]
*/
function genscore_clean_list( $list, $max ) {
$out = array();
foreach ( (array) $list as $q ) {
$q = sanitize_text_field( (string) $q );
if ( '' !== $q && ! in_array( $q, $out, true ) ) {
$out[] = $q;
}
}
return array_slice( $out, 0, $max );
}
/**
* The draft kind of a plan item: questions, compare, rivals or critics.
*
* @param array $item Plan item.
* @return string|null
*/
function genscore_draft_kind( $item ) {
$type = (string) ( $item['type'] ?? '' );
if ( 'close_competitor_gap' === $type ) {
return 'compare';
}
if ( 'add_comparison_content' === $type ) {
return 'rivals';
}
if ( 'address_negative_narrative' === $type ) {
return 'critics';
}
return 'questions';
}
/**
* The plan item, only when it asks for a new page.
*
* @param int $index Position in the plan.
* @return array|null { kind, key, title, questions, rival, rivals, drivers, brand }
*/
function genscore_draft_item( $index ) {
$d = genscore_plan_data();
if ( ! $d || ! isset( $d['plan'][ $index ] ) || ! is_array( $d['plan'][ $index ] ) || empty( $d['plan'][ $index ]['draft'] ) ) {
return null;
}
$item = $d['plan'][ $index ];
$kind = genscore_draft_kind( $item );
$out = array(
'kind' => $kind,
'type' => sanitize_key( (string) ( $item['type'] ?? '' ) ),
'title' => sanitize_text_field( (string) ( $item['title'] ?? '' ) ),
'questions' => genscore_clean_list( $item['questions'] ?? array(), GENSCORE_DRAFT_QUESTIONS ),
'rival' => sanitize_text_field( (string) ( $item['rival'] ?? '' ) ),
'drivers' => genscore_clean_list( $item['drivers'] ?? array(), 6 ),
'brand' => genscore_site_name(),
'rivals' => array(),
);
if ( '' === $out['brand'] ) {
$out['brand'] = sanitize_text_field( (string) ( $d['project']['brand'] ?? '' ) );
}
foreach ( (array) ( $d['project']['rivals'] ?? array() ) as $r ) {
if ( is_array( $r ) && empty( $r['isBrand'] ) && ! empty( $r['name'] ) ) {
$out['rivals'][] = sanitize_text_field( (string) $r['name'] );
}
}
$out['rivals'] = array_slice( array_values( array_unique( $out['rivals'] ) ), 0, GENSCORE_DRAFT_RIVALS );
if ( ( 'questions' === $kind && ! $out['questions'] ) || ( 'compare' === $kind && '' === $out['rival'] ) || ( 'rivals' === $kind && ! $out['rivals'] ) || ( 'critics' === $kind && ! $out['drivers'] ) ) {
return null;
}
$out['key'] = genscore_draft_key( $out );
return $out;
}
/**
* The key a draft is remembered by. The screen builds the same one
* (draftKey in admin.js).
*
* @param array $item From genscore_draft_item().
* @return string
*/
function genscore_draft_key( $item ) {
switch ( $item['kind'] ) {
case 'compare':
return 'compare:' . $item['rival'];
case 'rivals':
return 'rivals:' . $item['title'];
case 'critics':
return 'critics:' . $item['title'];
}
return $item['questions'][0];
}
/**
* The draft's title: the question or the topic for a page of questions;
* «Marca frente a Rival» for a comparison. Never a claim GenScore made up.
*
* @param array $item From genscore_draft_item().
* @return string
*/
function genscore_draft_title( $item ) {
switch ( $item['kind'] ) {
case 'compare':
/* translators: 1: brand, 2: rival. */
return sprintf( __( '%1$s frente a %2$s', 'genscore' ), $item['brand'], $item['rival'] );
case 'rivals':
/* translators: 1: brand, 2: rivals. */
return sprintf( __( '%1$s frente a %2$s', 'genscore' ), $item['brand'], genscore_join_names( $item['rivals'] ) );
case 'critics':
/* translators: %s: brand. */
return sprintf( __( 'Lo que debes saber de %s', 'genscore' ), $item['brand'] );
}
if ( 1 < count( $item['questions'] ) && preg_match( '/«([^»]+)»/u', $item['title'], $m ) ) {
return $m[1];
}
return $item['questions'][0];
}
/**
* «A, B y C».
*
* @param string[] $names Names.
* @return string
*/
function genscore_join_names( $names ) {
if ( count( $names ) < 2 ) {
return implode( '', $names );
}
$last = array_pop( $names );
/* translators: 1: names, 2: last name. */
return sprintf( __( '%1$s y %2$s', 'genscore' ), implode( ', ', $names ), $last );
}
/**
* An empty paragraph with a hint the editor shows in grey. It prints nothing
* until the person writes in it.
*
* @param string $hint Hint.
* @return string
*/
function genscore_block_hole( $hint ) {
return '
' . "\n\n";
}
/**
* A heading block.
*
* @param string $text Text.
* @param int $level Level.
* @return string
*/
function genscore_block_heading( $text, $level = 2 ) {
$attr = 2 === $level ? '' : ' ' . wp_json_encode( array( 'level' => $level ) );
return '' . esc_html( $text ) . '' . "\n\n";
}
/**
* The site's own name, as WordPress shows it.
*
* @return string
*/
function genscore_site_name() {
return trim( wp_strip_all_tags( html_entity_decode( get_bloginfo( 'name' ), ENT_QUOTES, 'UTF-8' ) ) );
}
/**
* One table cell; a «[Pendiente…]» gap is shown in grey.
*
* @param string $text Cell text.
* @return string
*/
function genscore_cell_html( $text ) {
$h = esc_html( $text );
if ( 0 !== strpos( (string) $text, GENSCORE_PENDING ) ) {
return $h;
}
// The editor's own inline text colour, so the block stays valid and the
// gap reads as a gap in the editor and on the page.
return '' . $h . '';
}
/**
* A table block.
*
* @param string[] $head Header cells.
* @param array[] $rows Rows of cells.
* @return string
*/
function genscore_block_table( $head, $rows ) {
$h = '' . implode( '', array_map( function ( $c ) {
return '| ' . esc_html( $c ) . ' | ';
}, $head ) ) . '
';
foreach ( $rows as $r ) {
$h .= '' . implode( '', array_map( function ( $c ) {
return '| ' . genscore_cell_html( $c ) . ' | ';
}, $r ) ) . '
';
}
return '' . "\n\n";
}
/**
* What the shop itself can say in a comparison, from WooCommerce. A row with
* nothing to say keeps a «[Pendiente…]» cell for the person.
*
* @return array[] [ label, own value ]
*/
function genscore_own_compare_rows() {
$pending = GENSCORE_PENDING . ': tu dato]';
$rows = array();
if ( function_exists( 'wc_get_products' ) ) {
$prices = array();
$count = 0;
$sum = 0.0;
foreach ( wc_get_products( array( 'status' => 'publish', 'limit' => 200, 'visibility' => 'catalog' ) ) as $p ) {
if ( '' !== (string) $p->get_price() ) {
$prices[] = (float) $p->get_price();
}
if ( (int) $p->get_review_count() > 0 ) {
$count += (int) $p->get_review_count();
$sum += (float) $p->get_average_rating() * (int) $p->get_review_count();
}
}
$price = $prices ? html_entity_decode( wp_strip_all_tags( wc_price( min( $prices ) ) ), ENT_QUOTES, 'UTF-8' ) : $pending;
$ship = genscore_shipping_settings();
$money = static function ( $v ) {
return html_entity_decode( wp_strip_all_tags( wc_price( (float) $v ) ), ENT_QUOTES, 'UTF-8' );
};
$ship_t = '' !== (string) $ship['cost'] ? ( 0.0 === (float) $ship['cost'] ? __( 'Gratis', 'genscore' ) : $money( $ship['cost'] ) ) . ( '' !== (string) $ship['free'] && (float) $ship['free'] > 0 ? ', ' . sprintf( /* translators: %s: amount. */ __( 'gratis desde %s', 'genscore' ), $money( $ship['free'] ) ) : '' ) : $pending;
$ret = get_option( GENSCORE_RETURNS_OPTION, array() );
$ret_t = is_array( $ret ) && ! empty( $ret['days'] ) ? sprintf( /* translators: %d: days. */ _n( '%d día', '%d días', (int) $ret['days'], 'genscore' ), (int) $ret['days'] ) : $pending;
$rate_t = $count ? sprintf( /* translators: 1: rating, 2: reviews. */ __( '%1$s de 5 (%2$d reseñas)', 'genscore' ), str_replace( '.', ',', (string) round( $sum / $count, 1 ) ), $count ) : $pending;
$rows[] = array( __( 'Precio desde', 'genscore' ), $price );
$rows[] = array( __( 'Envío', 'genscore' ), $ship_t );
$rows[] = array( __( 'Devoluciones', 'genscore' ), $ret_t );
$rows[] = array( __( 'Valoración', 'genscore' ), $rate_t );
return $rows;
}
foreach ( array( __( 'Qué ofrece', 'genscore' ), __( 'Precio', 'genscore' ), __( 'Para quién es', 'genscore' ) ) as $l ) {
$rows[] = array( $l, $pending );
}
return $rows;
}
/**
* Block markup of the draft.
*
* @param array $item From genscore_draft_item().
* @return string
*/
function genscore_draft_content( $item ) {
$out = '';
if ( in_array( $item['kind'], array( 'compare', 'rivals' ), true ) ) {
$rivals = 'compare' === $item['kind'] ? array( $item['rival'] ) : $item['rivals'];
$out .= genscore_block_hole( __( 'Resumen en dos frases: para quién es mejor cada opción.', 'genscore' ) );
$out .= genscore_block_heading( __( 'Comparativa rápida', 'genscore' ) );
$rows = array();
foreach ( genscore_own_compare_rows() as $r ) {
$rows[] = array_merge( $r, array_fill( 0, count( $rivals ), GENSCORE_PENDING . ': dato, enlace y fecha]' ) );
}
$out .= genscore_block_table( array_merge( array( __( 'Dato', 'genscore' ), $item['brand'] ), $rivals ), $rows );
foreach ( $item['questions'] as $q ) {
$out .= genscore_block_heading( $q ) . genscore_block_hole( __( 'Tu respuesta.', 'genscore' ) );
}
$out .= genscore_block_heading( __( 'Fuentes', 'genscore' ) );
$out .= genscore_block_hole( __( 'Enlace y fecha de cada dato de otra marca. Sin fuente, no lo publiques.', 'genscore' ) );
return $out;
}
if ( 'critics' === $item['kind'] ) {
$out .= genscore_block_hole( __( 'Una frase que diga qué haces y para quién, con tus palabras.', 'genscore' ) );
foreach ( $item['drivers'] as $d ) {
$out .= genscore_block_heading( mb_strtoupper( mb_substr( $d, 0, 1 ) ) . mb_substr( $d, 1 ) );
$out .= genscore_block_hole( __( 'Cómo es de verdad, con un dato que se pueda comprobar.', 'genscore' ) );
}
return $out;
}
$out .= genscore_block_hole( __( 'La respuesta corta, en dos o tres frases, antes de todo lo demás.', 'genscore' ) );
if ( in_array( $item['type'], array( 'add_citation_block', 'improve_citation_readiness' ), true ) ) {
$out .= genscore_block_hole( __( 'Un dato que se pueda comprobar, con el enlace a su fuente y su fecha.', 'genscore' ) );
}
foreach ( $item['questions'] as $q ) {
$out .= genscore_block_heading( $q ) . genscore_block_hole( __( 'Tu respuesta.', 'genscore' ) );
}
return $out;
}
/**
* A draft this plugin already made for that key, if it still exists.
*
* @param string $key Key.
* @return int Post id or 0.
*/
function genscore_existing_draft( $key ) {
$all = get_option( GENSCORE_DRAFTS_OPTION, array() );
$id = is_array( $all ) && isset( $all[ $key ] ) ? (int) $all[ $key ] : 0;
if ( ! $id ) {
return 0;
}
$status = get_post_status( $id );
return $status && 'trash' !== $status ? $id : 0;
}
/**
* What the screen needs: for each draft that still exists, its editor link.
*
* @return array [ key => edit url ]
*/
function genscore_drafts_screen_data() {
$all = get_option( GENSCORE_DRAFTS_OPTION, array() );
$out = array();
if ( is_array( $all ) ) {
foreach ( $all as $key => $id ) {
if ( genscore_existing_draft( $key ) ) {
$out[ $key ] = get_edit_post_link( (int) $id, 'raw' );
}
}
}
return $out;
}
/** AJAX: «Crear borrador». Takes only the plan position; the text comes from GenScore's answer. */
function genscore_ajax_create_draft() {
check_ajax_referer( 'genscore', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( null, 403 );
}
$index = isset( $_POST['plan'] ) ? absint( wp_unslash( $_POST['plan'] ) ) : -1;
$item = 0 <= $index && 10 > $index ? genscore_draft_item( $index ) : null;
if ( ! $item ) {
wp_send_json_error( array( 'message' => __( 'Esta recomendación ya no pide una página nueva. Actualiza la pantalla.', 'genscore' ) ) );
}
$key = $item['key'];
$id = genscore_existing_draft( $key );
$reused = (bool) $id;
if ( ! $id ) {
$id = wp_insert_post(
array(
'post_type' => 'questions' === $item['kind'] ? 'post' : 'page',
'post_status' => 'draft',
'post_title' => genscore_draft_title( $item ),
'post_content' => genscore_draft_content( $item ),
'post_author' => get_current_user_id(),
),
true
);
if ( is_wp_error( $id ) || ! $id ) {
wp_send_json_error( array( 'message' => __( 'WordPress no ha podido crear el borrador. Vuelve a intentarlo.', 'genscore' ) ) );
}
$all = get_option( GENSCORE_DRAFTS_OPTION, array() );
$all = is_array( $all ) ? $all : array();
$all[ $key ] = (int) $id;
update_option( GENSCORE_DRAFTS_OPTION, array_slice( $all, -50, null, true ), false );
}
wp_send_json_success(
array(
'key' => $key,
'edit' => get_edit_post_link( (int) $id, 'raw' ),
'reused' => $reused,
)
);
}
add_action( 'wp_ajax_genscore_create_draft', 'genscore_ajax_create_draft' );
/**
* A draft with cells still «[Pendiente…]» says so in the editor, so nothing
* about a rival goes live without its source.
*
* @param string $post_type Post type.
* @param WP_Post $post Post.
*/
function genscore_pending_meta_box( $post_type, $post ) {
if ( ! $post instanceof WP_Post || false === strpos( (string) $post->post_content, GENSCORE_PENDING ) ) {
return;
}
add_meta_box( 'genscore-pending', __( 'GenScore: huecos por rellenar', 'genscore' ), 'genscore_render_pending_meta_box', $post_type, 'side', 'high' );
}
add_action( 'add_meta_boxes', 'genscore_pending_meta_box', 10, 2 );
/**
* The pending box.
*
* @param WP_Post $post Post.
*/
function genscore_render_pending_meta_box( $post ) {
$n = substr_count( (string) $post->post_content, GENSCORE_PENDING );
/* translators: %d: number of cells. */
echo '' . esc_html( sprintf( _n( 'Queda %d hueco «[Pendiente…]» por rellenar.', 'Quedan %d huecos «[Pendiente…]» por rellenar.', $n, 'genscore' ), $n ) ) . '
';
echo '' . esc_html__( 'Cada dato de otra marca necesita su enlace y su fecha. Si no lo tienes, borra esa fila antes de publicar.', 'genscore' ) . '
';
}
/* ------------------------------------------------------------------ */
/* Plan items about a page that already exists */
/* ------------------------------------------------------------------ */
/**
* The page of this site a plan item names, if WordPress knows it.
*
* @param array $item Plan item.
* @return int Post id or 0.
*/
function genscore_plan_page_id( $item ) {
$url = isset( $item['page'] ) && is_string( $item['page'] ) ? esc_url_raw( $item['page'], array( 'http', 'https' ) ) : '';
if ( '' === $url ) {
return 0;
}
$id = url_to_postid( $url );
if ( ! $id ) {
$path = (string) wp_parse_url( $url, PHP_URL_PATH );
if ( '' === trim( $path, '/' ) ) {
$id = (int) get_option( 'page_on_front' );
}
}
return $id && 'publish' === get_post_status( $id ) && current_user_can( 'edit_post', $id ) ? (int) $id : 0;
}
/**
* For each plan item with a page here: its id, title, path and editor link.
*
* @return array [ index => { id, title, path, edit } ]
*/
function genscore_plan_pages_screen_data() {
$d = genscore_plan_data();
$out = array();
foreach ( $d ? $d['plan'] : array() as $i => $item ) {
$id = is_array( $item ) ? genscore_plan_page_id( $item ) : 0;
if ( $id ) {
$out[ $i ] = array(
'id' => $id,
'title' => genscore_post_title( $id ),
'path' => rawurldecode( (string) wp_parse_url( get_permalink( $id ), PHP_URL_PATH ) ),
'url' => get_permalink( $id ),
'edit' => get_edit_post_link( $id, 'raw' ),
'faq' => genscore_faq_pairs( $id ),
'cite' => genscore_cite_data( $id ),
'table' => 'close_competitor_gap' === ( $item['type'] ?? '' ) && ! empty( $item['rival'] ) ? genscore_compare_table_html( sanitize_text_field( (string) $item['rival'] ) ) : '',
);
}
}
return $out;
}
/**
* AJAX: «Añadir preguntas a la página». The page and the questions come from
* GenScore's answer; only the answers come from the screen. Undoable like
* every fix.
*/
function genscore_ajax_faq_publish() {
check_ajax_referer( 'genscore', 'nonce' );
genscore_ajax_guard();
$index = isset( $_POST['plan'] ) ? absint( wp_unslash( $_POST['plan'] ) ) : -1;
$batch = isset( $_POST['batch'] ) ? sanitize_key( wp_unslash( $_POST['batch'] ) ) : '';
$typed = isset( $_POST['answers'] ) ? json_decode( wp_unslash( $_POST['answers'] ), true ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every answer goes through genscore_clean_faq().
$d = genscore_plan_data();
$item = $d && 0 <= $index && isset( $d['plan'][ $index ] ) && is_array( $d['plan'][ $index ] ) ? $d['plan'][ $index ] : null;
$id = $item ? genscore_plan_page_id( $item ) : 0;
if ( ! $id || ! preg_match( '/^[a-z0-9]{12}$/', $batch ) || ! is_array( $typed ) ) {
wp_send_json_error( array( 'message' => __( 'Esta recomendación ya no apunta a una página de tu web. Actualiza la pantalla.', 'genscore' ) ), 400 );
}
$pairs = array();
foreach ( genscore_faq_pairs( $id ) as $p ) {
$pairs[ $p['q'] ] = $p['a'];
}
foreach ( genscore_clean_list( $item['questions'] ?? array(), GENSCORE_FAQ_MAX ) as $i => $q ) {
if ( isset( $typed[ $i ] ) && is_string( $typed[ $i ] ) && '' !== trim( $typed[ $i ] ) ) {
$pairs[ $q ] = $typed[ $i ];
}
}
$rows = array();
foreach ( $pairs as $q => $a ) {
$rows[] = array( 'q' => $q, 'a' => $a );
}
$value = genscore_clean_faq( wp_json_encode( $rows ) );
if ( null === $value ) {
wp_send_json_error( array( 'message' => __( 'Escribe al menos una respuesta de cinco palabras o más.', 'genscore' ) ), 400 );
}
$all = get_option( GENSCORE_BATCHES_OPTION, array() );
$all = is_array( $all ) ? $all : array();
$prev = genscore_fix_write( 'faq', $id, $value );
$all[ $batch ] = array( 'kind' => 'faq', 'at' => time(), 'ids' => array( $id ), 'prev' => array( $id => $prev ) );
if ( count( $all ) > 20 ) {
$all = array_slice( $all, -20, null, true );
}
update_option( GENSCORE_BATCHES_OPTION, $all, false );
genscore_forget_page_score( $id );
wp_send_json_success( array( 'done' => array( $id ), 'skipped' => array(), 'batches' => genscore_fix_batches_summary() ) );
}
add_action( 'wp_ajax_genscore_faq_publish', 'genscore_ajax_faq_publish' );
/**
* The comparison table with the shop's own column filled, for «Mejora tu
* comparativa»: the person pastes it in the comparison that already exists.
* The rival's column is always «[Pendiente…]».
*
* @param string $rival Rival.
* @return string HTML.
*/
function genscore_compare_table_html( $rival ) {
$brand = genscore_site_name();
$d = '' === $brand ? genscore_plan_data() : null;
if ( '' === $brand && $d && ! empty( $d['project']['brand'] ) ) {
$brand = sanitize_text_field( (string) $d['project']['brand'] );
}
$h = '| ' . esc_html__( 'Dato', 'genscore' ) . ' | ' . esc_html( $brand ) . ' | ' . esc_html( $rival ) . ' |
';
foreach ( genscore_own_compare_rows() as $r ) {
$h .= '| ' . esc_html( $r[0] ) . ' | ' . genscore_cell_html( $r[1] ) . ' | ' . genscore_cell_html( GENSCORE_PENDING . ': dato, enlace y fecha]' ) . ' |
';
}
return $h . '
';
}
/**
* AJAX: «Añadir dato con fuente» on the page a plan item names. The page
* comes from GenScore's answer; the fact, its link and its date from the
* person. Undoable like every fix.
*/
function genscore_ajax_cite_publish() {
check_ajax_referer( 'genscore', 'nonce' );
genscore_ajax_guard();
$index = isset( $_POST['plan'] ) ? absint( wp_unslash( $_POST['plan'] ) ) : -1;
$batch = isset( $_POST['batch'] ) ? sanitize_key( wp_unslash( $_POST['batch'] ) ) : '';
$d = genscore_plan_data();
$item = $d && 0 <= $index && isset( $d['plan'][ $index ] ) && is_array( $d['plan'][ $index ] ) ? $d['plan'][ $index ] : null;
$id = $item && in_array( $item['type'] ?? '', array( 'add_citation_block', 'improve_citation_readiness' ), true ) ? genscore_plan_page_id( $item ) : 0;
if ( ! $id || ! preg_match( '/^[a-z0-9]{12}$/', $batch ) ) {
wp_send_json_error( array( 'message' => __( 'Esta recomendación ya no apunta a una página de tu web. Actualiza la pantalla.', 'genscore' ) ), 400 );
}
$value = genscore_clean_cite( isset( $_POST['cite'] ) && is_array( $_POST['cite'] ) ? wp_unslash( $_POST['cite'] ) : array() ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every value is checked in genscore_clean_cite().
if ( null === $value ) {
wp_send_json_error( array( 'message' => __( 'Escribe el dato (cuatro palabras o más), el enlace a su fuente y su fecha, que no puede ser futura.', 'genscore' ) ), 400 );
}
$all = get_option( GENSCORE_BATCHES_OPTION, array() );
$all = is_array( $all ) ? $all : array();
$prev = genscore_fix_write( 'cite', $id, $value );
$all[ $batch ] = array( 'kind' => 'cite', 'at' => time(), 'ids' => array( $id ), 'prev' => array( $id => $prev ) );
if ( count( $all ) > 20 ) {
$all = array_slice( $all, -20, null, true );
}
update_option( GENSCORE_BATCHES_OPTION, $all, false );
genscore_forget_page_score( $id );
wp_send_json_success( array( 'batches' => genscore_fix_batches_summary(), 'cite' => genscore_cite_data( $id ) ) );
}
add_action( 'wp_ajax_genscore_cite_publish', 'genscore_ajax_cite_publish' );