*/ protected $dropped = array(); /** * Machine id, e.g. "yoast". * * @return string */ abstract public function id(); /** * Display name. * * @return string */ abstract public function name(); /** * Post meta keys by Orbitstem field. * * @return array */ abstract protected function post_keys(); /** * Source variable name (lowercase, without delimiters) => Orbitstem variable. * * @return array */ protected function variables() { return array(); } /** * Regex matching one whole source variable; group 1 is its name. * Empty when the source has no template variables. * * @return string */ protected function token_pattern() { return ''; } /** * Whether an unknown variable is removed (true) or left as plain text. * * @param string $name Lowercase variable name. * @return bool */ protected function drop_unknown( $name ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- subclasses decide per name. return true; } /** * Interprets a stored robots value. * * @param string $field 'noindex' or 'nofollow'. * @param mixed $raw Stored value. * @return bool */ protected function robots_flag( $field, $raw ) { return '1' === (string) $raw; } /** * Post IDs that may hold data, after a cursor, ascending. * * @param int $after Last processed ID. * @param int $limit Batch size. * @return int[] */ protected function candidate_post_ids( $after, $limit ) { global $wpdb; $keys = array_values( array_unique( $this->post_keys() ) ); if ( ! $keys ) { return array(); } $in = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); $args = array_merge( $keys, array( (int) $after, (int) $limit ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- one %s per key, built above; the sniff cannot count a spread array. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT m.post_id FROM {$wpdb->postmeta} m INNER JOIN {$wpdb->posts} p ON p.ID = m.post_id AND p.post_type NOT IN ('revision','nav_menu_item','attachment') AND p.post_status NOT IN ('trash','auto-draft','inherit') WHERE m.meta_key IN ({$in}) AND m.meta_value <> '' AND m.post_id > %d ORDER BY m.post_id ASC LIMIT %d", ...$args ) ); return array_map( 'intval', $ids ); } /** * Raw stored values for one post, keyed by Orbitstem field. * * @param int $post_id Post ID. * @return array */ protected function read_post_raw( $post_id ) { $raw = array(); foreach ( $this->post_keys() as $field => $key ) { $raw[ $field ] = get_post_meta( $post_id, $key, true ); } return $raw; } /** * Term IDs that may hold data, after a cursor. Sources without term data keep this default. * * @param int $after Last processed term ID. * @param int $limit Batch size. * @return int[] */ protected function candidate_term_ids( $after, $limit ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- signature shared with subclasses. return array(); } /** * Raw stored values for one term. Sources without term data keep this default. * * @param int $term_id Term ID. * @return array */ protected function read_term_raw( $term_id ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- signature shared with subclasses. return array(); } /** * Post IDs with something to import, after a cursor, ascending. * * @param int $after Last processed ID. * @param int $limit Batch size. * @return int[] */ public function post_ids_after( $after, $limit ) { return $this->importable_ids( array( $this, 'candidate_post_ids' ), array( $this, 'read_post' ), $after, $limit ); } /** * Term IDs with something to import, after a cursor, ascending. * * @param int $after Last processed ID. * @param int $limit Batch size. * @return int[] */ public function term_ids_after( $after, $limit ) { return $this->importable_ids( array( $this, 'candidate_term_ids' ), array( $this, 'read_term' ), $after, $limit ); } /** * Values for one post. * * @param int $post_id Post ID. * @return array */ public function read_post( $post_id ) { return $this->convert( $this->read_post_raw( $post_id ) ); } /** * Values for one term. * * @param int $term_id Term ID. * @return array */ public function read_term( $term_id ) { return $this->convert( $this->read_term_raw( $term_id ) ); } /** * Number of posts and terms with something to import. * * @return array{posts:int,terms:int} */ public function counts() { return array( 'posts' => $this->count_all( array( $this, 'post_ids_after' ) ), 'terms' => $this->count_all( array( $this, 'term_ids_after' ) ), ); } /** * Whether the source has anything to import. * * @return bool */ public function has_data() { return (bool) $this->post_ids_after( 0, 1 ) || (bool) $this->term_ids_after( 0, 1 ); } /** * Whether converted values contain anything worth importing. * * @param array $values Converted values. * @return bool */ public static function has_values( array $values ) { foreach ( array( 'title', 'description', 'canonical', 'focus_keyword' ) as $field ) { if ( isset( $values[ $field ] ) && '' !== $values[ $field ] ) { return true; } } return ! empty( $values['noindex'] ) || ! empty( $values['nofollow'] ); } /** * Replaces source variables with Orbitstem ones, matching whole variables * case-insensitively. Unknown variables are removed (or kept as text where * the source says so). * * @param string $text Text. * @return string */ public function convert_variables( $text ) { $text = (string) $text; if ( '' !== $this->token_pattern() ) { $map = $this->variables(); $text = preg_replace_callback( $this->token_pattern(), function ( $m ) use ( $map ) { $name = strtolower( $m[1] ); if ( isset( $map[ $name ] ) ) { return $map[ $name ]; } if ( $this->drop_unknown( $name ) ) { $this->dropped[ $m[0] ] = true; return ''; } return $m[0]; }, $text ); } return trim( preg_replace( '/\s+/', ' ', $text ) ); } /** * Source variables left out because Orbitstem has no equivalent, as written * in the source (e.g. %%currentyear%%), since this object was created. * * @return string[] */ public function dropped_variables() { return array_keys( $this->dropped ); } /** * Title and description templates and the separator, as Orbitstem settings * paths ("post_types.post.title") => converted value. Sources that store * templates override this. * * @return array */ public function templates() { return array(); } /** * Whether text contains variables Orbitstem cannot convert (they would be dropped). * * @param string $text Raw source text. * @return bool */ public function has_unsupported_variables( $text ) { if ( '' === $this->token_pattern() || ! preg_match_all( $this->token_pattern(), (string) $text, $matches ) ) { return false; } $map = $this->variables(); foreach ( $matches[1] as $name ) { $name = strtolower( $name ); if ( ! isset( $map[ $name ] ) && $this->drop_unknown( $name ) ) { return true; } } return false; } /** * Converts raw stored values to Orbitstem field values. * * @param array $raw Raw values keyed by Orbitstem field. * @return array */ protected function convert( array $raw ) { $out = array(); foreach ( array( 'title', 'description', 'canonical' ) as $field ) { $value = isset( $raw[ $field ] ) && is_scalar( $raw[ $field ] ) ? trim( (string) $raw[ $field ] ) : ''; $out[ $field ] = 'canonical' === $field ? $value : $this->convert_variables( $value ); } foreach ( array( 'noindex', 'nofollow' ) as $field ) { $out[ $field ] = isset( $raw[ $field ] ) ? $this->robots_flag( $field, $raw[ $field ] ) : false; } // Plain text, not a template. Plugins that allow several (Rank Math, SEOPress) keep them // comma-separated: the first is the main one, the others become extra keyphrases. $keyword = isset( $raw['focus_keyword'] ) && is_scalar( $raw['focus_keyword'] ) ? (string) $raw['focus_keyword'] : ''; $keywords = array_values( array_filter( array_map( 'trim', explode( ',', $keyword ) ), 'strlen' ) ); $out['focus_keyword'] = isset( $keywords[0] ) ? $keywords[0] : ''; $out['extra_keyphrases'] = implode( "\n", array_slice( $keywords, 1, \Orbitstem\SEO\Content\MetaRepository::MAX_EXTRA_KEYPHRASES ) ); return $out; } /** * Term IDs from term meta keys, after a cursor. * * @param string[] $keys Meta keys. * @param int $after Cursor. * @param int $limit Batch size. * @return int[] */ protected function term_ids_from_meta( array $keys, $after, $limit ) { global $wpdb; $keys = array_values( array_unique( $keys ) ); $in = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); $args = array_merge( $keys, array( (int) $after, (int) $limit ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- one %s per key, built above; the sniff cannot count a spread array. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT term_id FROM {$wpdb->termmeta} WHERE meta_key IN ({$in}) AND meta_value <> '' AND term_id > %d ORDER BY term_id ASC LIMIT %d", ...$args ) ); return array_map( 'intval', $ids ); } /** * Pages through candidate IDs, keeping only items with importable values. * Keeps fetching until $limit items are found or candidates run out, so an * empty page always means "no more". * * @param callable $candidates Returns candidate IDs after a cursor. * @param callable $read Returns converted values for an ID. * @param int $after Cursor. * @param int $limit Wanted count. * @return int[] */ private function importable_ids( callable $candidates, callable $read, $after, $limit ) { $found = array(); $cursor = (int) $after; $limit = max( 1, (int) $limit ); do { $batch = call_user_func( $candidates, $cursor, $limit ); $size = count( $batch ); foreach ( $batch as $id ) { $cursor = $id; if ( self::has_values( call_user_func( $read, $id ) ) ) { $found[] = $id; if ( count( $found ) >= $limit ) { return $found; } } } } while ( $size === $limit ); return $found; } /** * Counts IDs by paging through an *_ids_after() method. * * @param callable $page Method returning a page of IDs after a cursor. * @return int */ private function count_all( callable $page ) { $total = 0; $after = 0; do { $ids = call_user_func( $page, $after, 1000 ); $found = count( $ids ); $total += $found; $after = $found ? end( $ids ) : $after; } while ( 1000 === $found ); return $total; } }