'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 50, ); $offset = array( 'type' => 'integer', 'minimum' => 0, 'default' => 0, ); $job = array( 'type' => 'string', 'pattern' => '^[a-z0-9]{12,32}$', ); register_rest_route( 'threerb/v1', '/migration/scan', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'rest_scan' ), 'permission_callback' => $recht, ) ); register_rest_route( 'threerb/v1', '/migration/dry-run', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'rest_dry_run' ), 'permission_callback' => $recht, 'args' => array( 'offset' => $offset, 'limit' => $limit, ), ) ); register_rest_route( 'threerb/v1', '/migration/run', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'rest_run' ), 'permission_callback' => $recht, 'args' => array( 'job' => $job, 'limit' => $limit, ), ) ); register_rest_route( 'threerb/v1', '/migration/rollback', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'rest_rollback' ), 'permission_callback' => $recht, 'args' => array( 'job' => $job ), ) ); } /** * Nur Administratoren ziehen um. * * @return bool|WP_Error */ public static function permission() { if ( current_user_can( 'manage_options' ) ) { return true; } return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to do that.', '3task-review-box' ), array( 'status' => rest_authorization_required_code() ) ); } /** * REST: gefundene Beiträge. * * @return WP_REST_Response */ public static function rest_scan() { return rest_ensure_response( self::scan() ); } /** * REST: Probelauf. * * @param WP_REST_Request $request Anfrage. * @return WP_REST_Response */ public static function rest_dry_run( $request ) { return rest_ensure_response( self::dry_run( (int) $request['offset'], (int) $request['limit'] ) ); } /** * REST: Stapel umziehen. * * @param WP_REST_Request $request Anfrage. * @return WP_REST_Response|WP_Error */ public static function rest_run( $request ) { $job = isset( $request['job'] ) ? (string) $request['job'] : ''; return rest_ensure_response( self::run( $job, (int) $request['limit'] ) ); } /** * REST: Rückweg. * * @param WP_REST_Request $request Anfrage. * @return WP_REST_Response|WP_Error */ public static function rest_rollback( $request ) { $job = isset( $request['job'] ) ? (string) $request['job'] : ''; return rest_ensure_response( self::rollback( $job ) ); } /** * Ist WP Review (Free oder Pro) noch aktiv? * * @return bool */ public static function wp_review_active() { $aktiv = (array) get_option( 'active_plugins', array() ); if ( is_multisite() ) { $aktiv = array_merge( $aktiv, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); } foreach ( $aktiv as $datei ) { if ( in_array( (string) $datei, array( 'wp-review/wp-review.php', 'wp-review-pro/wp-review.php' ), true ) ) { return true; } } return false; } /** * Alle Beiträge mit WP-Review-Daten, mit Stand. * * @return array{total:int, ready:int, wp_review_active:bool, items:array} */ public static function scan() { $liste = array(); $bereit = 0; foreach ( self::candidates() as $id ) { $alt = self::read( $id ); if ( null === $alt ) { continue; } $stand = self::status( $id, $alt ); if ( 'ready' === $stand ) { ++$bereit; } $liste[] = array( 'id' => $id, 'title' => get_the_title( $id ), 'status' => $stand, 'scale' => $alt['scale'], 'edit' => (string) get_edit_post_link( $id, 'raw' ), ); } return array( 'total' => count( $liste ), 'ready' => $bereit, 'wp_review_active' => self::wp_review_active(), 'items' => $liste, ); } /** * Probelauf: alt gegen neu, nichts wird geschrieben. * * @param int $offset Beginn. * @param int $limit Anzahl, höchstens 50. * @return array{total:int, items:array} */ public static function dry_run( $offset, $limit ) { $alle = array(); foreach ( self::candidates() as $id ) { $alt = self::read( $id ); if ( null !== $alt ) { $alle[ $id ] = $alt; } } $teil = array_slice( $alle, max( 0, $offset ), max( 1, min( 50, $limit ) ), true ); $liste = array(); foreach ( $teil as $id => $alt ) { $neu = self::map( $id, $alt ); $liste[] = array( 'id' => $id, 'title' => get_the_title( $id ), 'status' => self::status( $id, $alt ), 'scale' => $alt['scale'], 'old_total' => $alt['total'], 'new_total' => $neu['new_total'], 'old_criteria' => count( $alt['items'] ), 'criteria' => count( $neu['box']['criteria'] ), 'override' => null !== $neu['box']['override']['value'], 'name' => $neu['quick']['name'], 'notes' => $neu['notes'], ); } return array( 'total' => count( $alle ), 'items' => $liste, ); } /** * Zieht den nächsten Stapel um. Wiederaufnehmbar: Umgezogene haben danach eine Box und werden übersprungen. * * @param string $job Vom Server vergebene Kennung, leer für einen neuen Lauf. * @param int $limit Stapelgröße 1 bis 50. * @return array|WP_Error */ public static function run( $job, $limit ) { $job = self::lock( $job ); if ( is_wp_error( $job ) ) { return $job; } $limit = max( 1, min( 50, (int) $limit ) ); $protokoll = self::log(); $umgezogen = 0; $fehler = array(); $offen = 0; foreach ( self::candidates() as $id ) { $alt = self::read( $id ); if ( null === $alt || 'ready' !== self::status( $id, $alt ) ) { continue; } if ( $umgezogen >= $limit ) { ++$offen; continue; } $neu = self::map( $id, $alt ); ThreeRB_Box::save( $id, $neu['box'] ); ThreeRB_Box::save_quick( $id, $neu['quick'] ); ThreeRB_Box::mirror( $id ); $protokoll['entries'][ $id ] = array( 'job' => $job, 'at' => time(), 'hash' => self::hash( $id ), ); ++$umgezogen; if ( ! ThreeRB_Box::has_rating( ThreeRB_Box::get( $id ) ) ) { $fehler[] = $id; } } update_option( self::LOG, $protokoll, false ); if ( 0 === $offen ) { delete_option( self::LOCK ); } return array( 'job' => $job, 'moved' => $umgezogen, 'remaining' => $offen, 'no_rating' => $fehler, ); } /** * Nimmt den Umzug zurück: löscht nur unsere Felder der protokollierten Beiträge, * und nur, wenn sie seit dem Umzug nicht geändert wurden. * * @param string $job Nur diesen Lauf, leer für alle. * @return array|WP_Error */ public static function rollback( $job ) { $sperre = get_option( self::LOCK ); if ( is_array( $sperre ) && isset( $sperre['expires'] ) && (int) $sperre['expires'] > time() ) { return new WP_Error( 'threerb_migration_running', __( 'A move is still running. Finish it first.', '3task-review-box' ), array( 'status' => 409 ) ); } $protokoll = self::log(); $entfernt = 0; $behalten = array(); foreach ( $protokoll['entries'] as $id => $eintrag ) { if ( '' !== $job && ( ! isset( $eintrag['job'] ) || $eintrag['job'] !== $job ) ) { continue; } $id = (int) $id; unset( $protokoll['entries'][ $id ] ); if ( ! get_post( $id ) ) { continue; } if ( ! isset( $eintrag['hash'] ) || self::hash( $id ) !== $eintrag['hash'] ) { $behalten[] = $id; continue; } delete_post_meta( $id, ThreeRB_Box::META_BOX ); delete_post_meta( $id, ThreeRB_Box::META_QUICK ); ThreeRB_Box::mirror( $id ); ++$entfernt; } if ( empty( $protokoll['entries'] ) ) { delete_option( self::LOG ); } else { update_option( self::LOG, $protokoll, false ); } return array( 'removed' => $entfernt, 'kept' => $behalten, ); } /** * Liest die WP-Review-Daten eines Beitrags, oder null, wenn er keine Bewertung hat. * * @param int $id Beitrag. * @return array|null */ public static function read( $id ) { $typ = get_post_meta( $id, 'wp_review_type', true ); $typ = is_string( $typ ) ? trim( $typ ) : ''; $items = get_post_meta( $id, 'wp_review_item', true ); $items = is_array( $items ) ? array_values( $items ) : array(); $total = self::number( get_post_meta( $id, 'wp_review_total', true ) ); if ( 'none' === $typ ) { return null; } if ( '' === $typ ) { // Falle 1: leer heißt „globale Einstellung“, gilt aber nur, wenn wirklich Daten da sind. $global = get_option( 'wp_review_options', array() ); $typ = is_array( $global ) && isset( $global['review_type'] ) && is_string( $global['review_type'] ) ? $global['review_type'] : 'star'; if ( 'none' === $typ || ( empty( $items ) && null === $total ) ) { return null; } } $skalen = array( 'star' => 's5', 'point' => 'p10', 'percentage' => 'pct', 'circle' => 'pct', ); return array( 'type' => $typ, 'scale' => isset( $skalen[ $typ ] ) ? $skalen[ $typ ] : '', 'heading' => self::string( get_post_meta( $id, 'wp_review_heading', true ) ), 'items' => $items, 'total' => $total, 'desc' => self::string( get_post_meta( $id, 'wp_review_desc', true ) ), 'pros' => self::string( get_post_meta( $id, 'wp_review_pros', true ) ), 'cons' => self::string( get_post_meta( $id, 'wp_review_cons', true ) ), 'location' => self::string( get_post_meta( $id, 'wp_review_location', true ) ), 'schema' => self::string( get_post_meta( $id, 'wp_review_schema', true ) ), 'links' => get_post_meta( $id, 'wp_review_links', true ), ); } /** * Übersetzt alte Daten in Box und Schnellbereich (noch nicht gespeichert). * * @param int $id Beitrag. * @param array $alt Ergebnis von read(). * @return array{box:array, quick:array, new_total:float|null, notes:array} */ public static function map( $id, array $alt ) { $notizen = array(); $scale = '' !== $alt['scale'] ? $alt['scale'] : 'p10'; $max = ThreeRB_Score::max( $scale ); $kriterien = array(); $gueltig = array(); foreach ( $alt['items'] as $item ) { if ( ! is_array( $item ) ) { $notizen[] = 'criterion_skipped'; continue; } $titel = isset( $item['wp_review_item_title'] ) ? self::string( $item['wp_review_item_title'] ) : ''; $note = isset( $item['wp_review_item_star'] ) ? self::number( $item['wp_review_item_star'] ) : null; if ( '' === trim( $titel ) || null === $note || $note < 0 || $note > $max ) { $notizen[] = 'criterion_skipped'; continue; } $gueltig[] = array( 'label' => $titel, 'score' => $note, ); } if ( count( $gueltig ) > 12 ) { $notizen[] = 'criteria_truncated'; $gueltig = array_slice( $gueltig, 0, 12 ); } $gewicht = count( $gueltig ) ? intdiv( 100, count( $gueltig ) ) : 0; foreach ( $gueltig as $k ) { $kriterien[] = array( 'label' => $k['label'], 'weight' => $gewicht, 'score' => $k['score'], ); } $fazit_roh = preg_replace( '@<(script|style)[^>]*?>.*?@si', '', $alt['desc'] ); $fazit = self::strip_shortcodes( (string) $fazit_roh ); if ( $fazit !== $fazit_roh ) { $notizen[] = 'shortcodes_removed'; } $links = array(); if ( is_array( $alt['links'] ) ) { foreach ( $alt['links'] as $l ) { if ( is_array( $l ) && isset( $l['url'] ) ) { $links[] = array( 'label' => isset( $l['text'] ) ? self::string( $l['text'] ) : '', 'url' => self::string( $l['url'] ), ); } } if ( $links ) { $notizen[] = 'links_check_affiliate'; } } $typ = self::schema_type( $alt['schema'] ); if ( 'none' === $typ && 'none' !== strtolower( $alt['schema'] ) ) { $notizen[] = 'schema_type_unsupported'; } if ( in_array( $typ, array( 'local', 'organization' ), true ) ) { $notizen[] = 'check_business'; } $roh = array( 'item' => array( 'type' => $typ ), 'scale' => $scale, 'step' => 'pct' === $scale ? 1 : 0.1, 'criteria' => $kriterien, 'pros' => self::points( $alt['pros'] ), 'cons' => self::points( $alt['cons'] ), 'verdict' => $fazit, 'links' => $links, 'display' => 's5' === $scale ? 'stars' : 'default', ); if ( count( $roh['pros'] ) > 10 || count( $roh['cons'] ) > 10 ) { $notizen[] = 'points_truncated'; } $sauber = ThreeRB_Box::clean( $roh ); if ( in_array( 'verdict', $sauber['errors'], true ) ) { $notizen[] = 'verdict_shortened'; } $box = $sauber['box']; $name = $alt['heading']; if ( '' === trim( $name ) ) { $name = get_the_title( $id ); $notizen[] = 'name_from_title'; } $ort = $alt['location']; $quick = ThreeRB_Box::quick_clean( array( 'score' => $kriterien ? null : $alt['total'], 'name' => $name, 'place' => array( 'top' => 'top' === $ort, 'bottom' => 'custom' !== $ort, 'sidebar' => false, ), ) ); if ( 'custom' === $ort ) { $notizen[] = 'shortcode_bridge'; } $alt_note = null !== $alt['total'] ? ThreeRB_Score::snap( $alt['total'], $scale, $box['step'] ) : null; $probe = $box; $probe['score'] = null !== $quick['score'] ? ThreeRB_Score::snap( $quick['score'], $scale, $box['step'] ) : null; $gerechnet = ThreeRB_Score::total( $probe )['value']; if ( $kriterien && null !== $alt_note && null !== $gerechnet && abs( $gerechnet - $alt_note ) > 0.0001 ) { $box['override'] = array( 'value' => $alt_note, 'reason' => self::REASON, ); $notizen[] = 'total_kept'; $probe['override'] = $box['override']; $gerechnet = ThreeRB_Score::total( $probe )['value']; } if ( null === $gerechnet ) { $notizen[] = 'no_rating'; } return array( 'box' => $box, 'quick' => $quick, 'new_total' => $gerechnet, 'notes' => array_values( array_unique( $notizen ) ), ); } /** * Stand eines gefundenen Beitrags. * * @param int $id Beitrag. * @param array $alt Alte Daten. * @return string ready, has_box oder unsupported. */ private static function status( $id, array $alt ) { if ( '' === $alt['scale'] ) { return 'unsupported'; } if ( '' !== get_post_meta( $id, ThreeRB_Box::META_BOX, true ) || '' !== get_post_meta( $id, ThreeRB_Box::META_QUICK, true ) ) { return 'has_box'; } return 'ready'; } /** * Beitrags-IDs mit dem Feld wp_review_type, auch wenn WP Review deaktiviert ist. * * @return array */ private static function candidates() { $typen = array_values( array_diff( get_post_types( array( 'public' => true ), 'names' ), array( 'attachment' ) ) ); $ids = array(); $seite = 1; do { $teil = get_posts( array( 'post_type' => $typen, 'post_status' => array( 'publish', 'future', 'draft', 'pending', 'private' ), 'posts_per_page' => 500, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- Stapel nur mit IDs, im Admin. 'paged' => $seite, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true, 'update_post_term_cache' => false, 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Umzug, nur im Admin. array( 'key' => 'wp_review_type', 'compare' => 'EXISTS', ), ), ) ); $ids = array_merge( $ids, array_map( 'intval', $teil ) ); $anzahl = count( $teil ); ++$seite; } while ( 500 === $anzahl ); return $ids; } /** * Sperre gegen parallele Läufe, mit Ablauf. * * @param string $job Kennung oder leer. * @return string|WP_Error Kennung. */ private static function lock( $job ) { $sperre = get_option( self::LOCK ); $aktiv = is_array( $sperre ) && isset( $sperre['job'], $sperre['expires'] ) && (int) $sperre['expires'] > time(); if ( $aktiv && $sperre['job'] !== $job ) { return new WP_Error( 'threerb_migration_running', __( 'Another move is running. Wait a few minutes or continue that one.', '3task-review-box' ), array( 'status' => 409 ) ); } if ( '' === $job ) { $job = strtolower( wp_generate_password( 16, false, false ) ); } $wert = array( 'job' => $job, 'expires' => time() + self::LOCK_TTL, ); if ( ! $aktiv && false === $sperre ) { if ( ! add_option( self::LOCK, $wert, '', false ) ) { return new WP_Error( 'threerb_migration_running', __( 'Another move is running. Wait a few minutes or continue that one.', '3task-review-box' ), array( 'status' => 409 ) ); } } else { update_option( self::LOCK, $wert, false ); } return $job; } /** * Umzugsprotokoll. * * @return array{entries:array} */ private static function log() { $protokoll = get_option( self::LOG, array() ); if ( ! is_array( $protokoll ) || ! isset( $protokoll['entries'] ) || ! is_array( $protokoll['entries'] ) ) { $protokoll = array( 'entries' => array() ); } return $protokoll; } /** * Fingerabdruck unserer Felder, um spätere Änderungen von Hand zu erkennen. * * @param int $id Beitrag. * @return string */ private static function hash( $id ) { return md5( (string) get_post_meta( $id, ThreeRB_Box::META_BOX, true ) . '|' . (string) get_post_meta( $id, ThreeRB_Box::META_QUICK, true ) ); } /** * WP-Review-Schematyp in unseren Typ. * * @param string $typ Typ aus wp_review_schema. * @return string */ private static function schema_type( $typ ) { $liste = array( 'Product' => 'product', 'Movie' => 'movie', 'TVSeries' => 'series', 'CreativeWorkSeason' => 'season', 'Episode' => 'episode', 'VideoGame' => 'game', 'Game' => 'game', 'SoftwareApplication' => 'software', 'Book' => 'book', 'Recipe' => 'recipe', 'Course' => 'course', 'Event' => 'event', 'MusicRecording' => 'music', 'MusicPlaylist' => 'playlist', 'LocalBusiness' => 'local', 'Organization' => 'organization', ); return isset( $liste[ $typ ] ) ? $liste[ $typ ] : 'none'; } /** * Listenpunkte aus dem HTML von WP Review Pro (meist ul/li, teils Absätze oder Zeilen). * * @param string $html HTML. * @return array */ private static function points( $html ) { $html = self::strip_shortcodes( (string) preg_replace( '@<(script|style)[^>]*?>.*?@si', '', $html ) ); if ( preg_match_all( '#]*>(.*?)#si', $html, $treffer ) ) { $teile = $treffer[1]; } else { $teile = preg_split( '#|

|\n#i', $html ); } $punkte = array(); foreach ( (array) $teile as $teil ) { $text = trim( html_entity_decode( wp_strip_all_tags( (string) $teil ), ENT_QUOTES, 'UTF-8' ) ); $text = trim( $text, " \t-•*" ); if ( '' !== $text ) { $punkte[] = $text; } } return $punkte; } /** * Entfernt Shortcodes (registrierte und die von WP Review), statt sie je auszuführen. * * @param string $text Text. * @return string */ private static function strip_shortcodes( $text ) { global $shortcode_tags; if ( false === strpos( $text, '[' ) ) { return $text; } $namen = array_merge( array_keys( is_array( $shortcode_tags ) ? $shortcode_tags : array() ), array( 'wp-review', 'wp_review', 'wp-review-total', 'wp_review_total', 'wp-review-visitor-rating', 'wp_review_visitor_rating', 'select-review' ) ); $muster = get_shortcode_regex( array_unique( $namen ) ); return trim( (string) preg_replace( "/$muster/", '', $text ) ); } /** * Zahl aus Text mit Punkt oder Komma. * * @param mixed $wert Wert. * @return float|null */ private static function number( $wert ) { if ( is_int( $wert ) || is_float( $wert ) ) { return is_finite( (float) $wert ) ? (float) $wert : null; } if ( ! is_string( $wert ) ) { return null; } $wert = str_replace( ',', '.', trim( $wert ) ); return preg_match( '/^\d+(\.\d+)?$/', $wert ) ? (float) $wert : null; } /** * Nur Zeichenketten, sonst leer. * * @param mixed $wert Wert. * @return string */ private static function string( $wert ) { return is_string( $wert ) || is_int( $wert ) || is_float( $wert ) ? (string) $wert : ''; } }