php_Indexer.php

<?php
namespace FindStr;

use MeiliSearch\Client;
use MeiliSearch\Endpoints\Indexes;

/**
* Indexer class
* @package FindStr
*
* Singleton class
*/
class Indexer {

  /**
   @var Indexer|null
   */
  private static ?Indexer $instance = null;

    /**
     * The MeiliSearch server url
     *
     * @var string $meilisearch_server
     */
  private string $meilisearch_server;

    /**
     * The MeiliSearch index
     *
     * @var string $meilisearch_index
     */
  private string $meilisearch_index;

  /**
   * @var false|mixed|object
   */
  private $settings;



    /**
     * The MeiliSearch Api "master key"
     *
     * @var string $meilisearch_private_key
     */
  private string $meilisearch_private_key;

  /**
   * The post types to index
   * @var array
   */
  private array $post_types_to_index;


    /**
     * The number of posts to index at once.
     *
     * @var int
     */
  private int $post_to_index_batch_size = 121;

    /**
     * The filterable attributes.
     *
     * @var array $filterable_attributes
     */
  private array $filterable_attributes = array();

    /**
     * The sortable attributes.
     *
     * @var array $sortable_attributes
     */
  private array $sortable_attributes = array();

  /**
   * IDs to delete
   * @var array
   */
  private array $ids_to_delete;

  /**
   * Post status to index
   * @var array
   */
  private array $post_status_to_index;

  /**
   * Initialize the class and set its properties.
   */
  private function __construct() {
    //get all settings
    $server_settings = ( new ServerSettings() )->get();

    $settings       = ( new Settings() )->get();
    $this->settings = $settings;

    $this->meilisearch_index       = $server_settings->indexUID ?? false;
    $this->meilisearch_server      = $server_settings->serverUrl ?? false;
    $this->meilisearch_private_key = $server_settings->privateKey ?? false;

    /**
     * This filter is used to set the number of posts to index at once.
     *
     * @hook findstr_index_batch_size
     *
     * @param {int} $batch_size
     *
     * @return {int} $batch_size
     */
    $this->post_to_index_batch_size = apply_filters( 'findstr_index_batch_size', $this->post_to_index_batch_size );

    $this->post_types_to_index = $settings->postTypesToIndex ?? array();

    add_action( 'findstr_index_documents', array( $this, 'batch_index' ), 10, 2 );

    add_action( 'findstr_delete_documents', array( $this, 'batch_delete' ) );

    add_action( 'save_post', array( $this, 'save_post' ), 888, 2 );

    add_action( 'delete_post', array( $this, 'delete' ), 888, 2 );

    add_action( 'shutdown', array( $this, 'indexer_shutdown' ) );

    add_action( 'findstr_after_update', array( $this, 'update_index_attributes' ) );

    return $this;
  }

  /**
   * @return Indexer
   */
  public static function get_instance(): Indexer {
    if ( ! isset( self::$instance ) ) {
      self::$instance = new self();
    }
    return self::$instance;
  }

  private function get_index(): Indexes {

    $http_client = new \GuzzleHttp\Client(
      array(
        'timeout' => 10,
        'verify'  => false,
      )
    );

    $client = new Client( $this->meilisearch_server, $this->meilisearch_private_key, $http_client );

    return $client->index( $this->meilisearch_index );
  }

  /**
   * Start indexing process
   *
   * This function will launch indexing processes and delete the obsoletes posts from index.
   *
   * @return array|bool
   */
  public function start_indexer() {

    new Log( 'Start indexer...', 'info', array() );

    if ( ! $this->is_indexer_done() ) {
      new Log( 'Indexer already running, skip', 'info', array() );
      return false;
    }

    //reset last indexed ids
    update_option( 'findstr_current_indexed_ids', array(), false );
    update_option( 'findstr_current_index_key', current_time( 'mysql' ) ); //key is used to deleted documents from index

    $this->set_indexing_status( true );

    //early set sortable and filterable attributes
    $this->update_index_attributes();

    $posts_ids_to_index = $this->get_post_ids_to_index();
    if ( empty( $posts_ids_to_index ) ) {
      new Log( 'Nothing to index, bye !', 'info', array() );
      $this->set_indexing_status( false );
      return false;
    }

    $all_documents_ids = array_merge( array(), ...array_values( $posts_ids_to_index ) );

    /**
     * Delete documents
     */
    $posts_ids_to_delete = $this->get_post_ids_to_delete( $all_documents_ids );
    if ( ! empty( $posts_ids_to_delete ) ) {

      new Log( sprintf( 'Found %d documents to delete...', count( $posts_ids_to_delete ) ), 'info' );

      $batches = array_chunk( $posts_ids_to_delete, $this->post_to_index_batch_size );
      foreach ( $batches as $batch ) {
        new Log( sprintf( 'Enqueue a batch of %d documents to delete action', count( $batch ) ), 'debug', array() );
        as_enqueue_async_action( 'findstr_delete_documents', array( $batch ), 'findstr_delete_batch' );
      }
    }

    /**
     * Index documents
     */
    foreach ( $posts_ids_to_index as $language => $ids ) {

      new Log( sprintf( 'There is %d documents to index for language %s', count( $ids ), $language ), 'info', array() );

      $batches = array_chunk( $ids, $this->post_to_index_batch_size );

      foreach ( $batches as $batch_ids ) {
        new Log( sprintf( 'Enqueue a batch of %d documents to index action', count( $batch_ids ) ), 'debug', array() );
        as_enqueue_async_action( 'findstr_index_documents', array( $batch_ids, $language ), 'findstr_index_batch' );
      }
    }

    /**
     * Update last indexed and deleted ids
     */
    update_option( 'findstr_last_indexed_ids', $all_documents_ids, false );
    update_option( 'findstr_last_deleted_ids', $posts_ids_to_delete, false );

    $status = $this->status();

    $status['pending_documents'] = count( $all_documents_ids );

    $this->write_status_to_file( $status );

    //remove action shutdown, to avoid multiple status writing in the same request
    remove_action( 'shutdown', array( $this, 'indexer_shutdown' ) );

    return $status;
  }


  /**
   * Attempt to index an array of documents
   *
   * @param array $ids
   *
   * @return void
   */
  public function index( array $ids ) {

    /**
     * This constant is meant to be used by front-end, if anyone wants to know if findstr is indexing.
     */
    if ( ! defined( 'DOING_FINDSTR_INDEX' ) ) {
      define( 'DOING_FINDSTR_INDEX', true );
    }

    $documents = array();

    new Log( 'Index documents', 'info', array( $ids, get_locale() ) );
    $start_time = microtime( true );

    $document_key = get_option( 'findstr_current_index_key', 'false' );

    foreach ( $ids as $post_id ) {

      if ( true !== apply_filters( 'findstr_document_should_be_indexed', true, $post_id ) ) {
        new Log( 'Document should be deleted', 'info', array( $post_id ) );
        $this->delete( $post_id );
        continue;
      }

      try {
        $document                         = new Document( $post_id );
        $document_to_index                = $document->to_index;
        $document_to_index['findstr_key'] = $document_key;
        $documents[]                      = $document_to_index;
      } catch ( \Exception $e ) {
        new Log(
          'Document error',
          'error',
          array(
            'message' => $e->getMessage(),
            'post ID' => $post_id,
          )
        );
        continue;
      }
    }

    $end_time       = microtime( true );
    $execution_time = $end_time - $start_time;
    new Log( sprintf( 'Documents rendered in %s seconds', $execution_time ), 'debug', array() );

    try {
      $this->get_index()->addDocuments( $documents );

      $end_time       = microtime( true );
      $execution_time = $end_time - $start_time;
      new Log( sprintf( 'Sent %d documents to indexer after %s seconds', count( $documents ), $execution_time ), 'debug', array() );

    } catch ( \Exception $e ) {
      //todo: do something with non-indexed documents ?
      new Log(
        'Index error',
        'error',
        array(
          'message' => $e->getMessage(),
          'ids'     => $ids,
        )
      );
    }

    //update last indexed ids
    $indexed_ids = get_option( 'findstr_current_indexed_ids', array() );
    update_option( 'findstr_current_indexed_ids', array_merge( $indexed_ids, $ids ), false );
  }



  public function get_post_types_to_index(): array {
    $this->post_types_to_index = ( new Settings() )->get_post_types_to_index();
    return $this->post_types_to_index;
  }

  public function get_post_status_to_index(): array {
    /**
     * This filter is used to set the post status to index.
     *
     * @hook findstr_index_post_status
     *
     * @param {array} $post_status_to_index, default is array( 'publish' )
     *
     * @return {array} $post_status_to_index, use WP_Post Status
     */
    $this->post_status_to_index = apply_filters( 'findstr_index_post_status', array( 'publish' ) );
    return $this->post_status_to_index;
  }


  public function get_ids_to_index_query( $args = array() ): array {

    /**
     * This query is used to get all posts to index.
     *
     * @hook findstr_index_args
     *
     * @param {array} $query
     *
     * @return {array} WP_Query arguments
     */
    $default_query = apply_filters(
      'findstr_index_args',
      array(
        'posts_per_page'         => - 1,
        'post_status'            => $this->get_post_status_to_index(),
        'post_type'              => $this->get_post_types_to_index(),
        'suppress_filters'       => true,
        'fields'                 => 'ids',
        'orderby'                => 'ID',
        'cache_results'          => false,
        'no_found_rows'          => true,
        'update_post_meta_cache' => false,
        'update_post_term_cache' => false,
      )
    );

    return Helpers::merge_args( $args, $default_query );
  }

  /**
   * Get posts to index, by language
   *
   *
   * @return array
   */
  public function get_post_ids_to_index(): array {

      $query = $this->get_ids_to_index_query();

      $query = new \WP_Query( $query );

      /**
       * Use this filter to modify the ids of posts to index.
       *
       *
       * @hook findstr_index_ids
       *
       * @param {array} $ids of posts to index
       *
       * @return {array} $ids of posts to index
       *
       */
      $post_ids = apply_filters( 'findstr_index_ids', $query->posts );

      $result = array();

    foreach ( $post_ids as $post_id ) {

      $language_code = Helpers::get_language_code_by_post_id( $post_id );

      if ( is_array( $language_code ) ) {
        $language_code = current( $language_code );
      }
      $result[ $language_code ][] = $post_id;
    }

    /**
     * Filter the post ids to index, sorted by languages.
     *
     *
     * @hook findstr_index_ids_sorted_by_languages
     *
     * @param {array} languages and their posts ids
     *
     * @return {array} languages and their posts ids
     *
     */
    return apply_filters( 'findstr_index_ids_sorted_by_languages', $result );
  }

  public function get_post_ids_to_index_count(): int {

    $query = $this->get_ids_to_index_query(
      array(
        'posts_per_page' => 1,
        'no_found_rows'  => false,
        'cache_results'  => true,
      )
    );

    $query = new \WP_Query( $query );

    return (int) apply_filters( 'findstr_indexable_ids_count', $query->found_posts, $query );
  }

  /**
   * Save post actions
   *
   * @param $post_id
   * @param $post
   * @return void
   */
  public function save_post( $post_id, $post ) {

    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
      new Log( 'Autosave or revision, skip indexing', 'debug', array( 'ID', $post_id ) );
      return;
    }

    //check if post type is in the list of post types to index
    if ( ! in_array( $post->post_type, $this->get_post_types_to_index(), true ) ) {
      new Log( 'Post type is not in the list of post types to index', 'debug', array( 'ID', $post_id ) );
      return;
    }

    /**
     * This filter is used to disable indexing on save action.
     * Useful in case of import or automated posts creation.
     *
     * @hook findstr_disable_indexing_on_save
     *
     * @param {bool} $disable_indexing_on_save
     * @param {int} $post_id
     * @param {WP_Post} $post
     *
     * @return {bool} $disable_indexing_on_save
     */
    $disable_indexing_on_save = apply_filters( 'findstr_disable_indexing_on_save', false, $post_id, $post );

    if ( true === $disable_indexing_on_save ) {
      new Log( 'Indexing is disabled on save action', 'info' );
      return;
    }

    /**
     * This filter is used to determine if a document should be indexed.
     *
     * @hook findstr_document_should_be_indexed
     *
     * @param {bool} $should_index
     * @param {int} $post_id
     *
     * @return {bool} $should_index
     */
    $should_index = apply_filters( 'findstr_document_should_be_indexed', true, $post_id );

    if ( true !== $should_index ) {
      new Log( 'Document should not be indexed', 'info', array( 'ID', $post_id ) );
      return;
    }

    $language = Helpers::get_language_code_by_post_id( $post_id );
    if ( is_array( $language ) ) {
      $language = current( $language );
    }

    //check if action is already scheduled
    if ( as_get_scheduled_actions(
      array(
        'hook'   => 'findstr_index_documents',
        'args'   => array(
          array( $post_id ),
          $language,
        ),
        'group'  => 'findstr_index_batch',
        'status' => \ActionScheduler_Store::STATUS_PENDING,
      )
    ) ) {
      new Log( 'Document is already scheduled for indexing', 'info', array( 'ID', $post_id ) );
      return;
    }

    //check if post status is in the list of post status to index
    if ( true === $should_index && in_array( $post->post_status, $this->get_post_status_to_index(), true ) ) {

      new Log( sprintf( 'Save post %d in lang %s', $post_id, $language ), 'debug' );
      as_schedule_single_action( time() + 30, 'findstr_index_documents', array( array( $post_id ), $language ), 'findstr_index_on_save' );

    } else {
      new Log( sprintf( 'Remove post %d from index', $post_id ), 'debug' );
      $this->delete( $post_id );
    }
  }



  public function write_status_to_file( $status = null ): void {

    new Log( 'Writing status file', 'debug', array( 'status' => $status ) );

    if ( empty( $status ) ) {
      $status = $this->status();
    }
    $path = Helpers::get_index_status_path();

    Helpers::file_put_content( $path, wp_json_encode( $status ) );
  }


  /**
   * Attempt to index a single document
   *
   * @param $id
   * @return void
   */
  public function delete( $id, $post = null ) {
    try {
      $this->get_index()->deleteDocument( $id );
      new Log( sprintf( 'Delete document %s', $id ), 'debug', array( 'post' => $post ) );
    } catch ( \Exception $e ) {
      new Log( 'Delete error', 'error', array( 'message' => $e->getMessage() ) );
    }
  }

  /**
   * Attempt to delete a batch of documents
   *
   * @param array $ids
   * @return void
   */
  public function batch_delete( array $ids = array() ) {
    new Log( sprintf( 'Deleting %d documents.', count( $ids ) ), 'debug', array( 'ids' => $ids ) );

    try {
      $this->get_index()->deleteDocuments( $ids );
    } catch ( \Exception $e ) {
      new Log( 'Batch delete error', 'error', array( 'message' => $e->getMessage() ) );
    }
  }


  /**
   * Set indexing status
   *
   * @param bool $indexing
   *
   * @return void
   */
  private function set_indexing_status( bool $indexing ) {

    global $is_indexing;

    $is_indexing = $indexing;

    new Log( 'Set indexing status to ' . ( $indexing ? 'true' : 'false' ), 'info', array() );

    global $wpdb;
    $wpdb->query(
      $wpdb->prepare(
        "INSERT into $wpdb->options ( option_name, option_value ) value ( %s, %s ) ON DUPLICATE KEY UPDATE option_value = %s",
        'findstr_is_indexing',
        $indexing,
        $indexing
      )
    );
  }

  /**
   * Check if server is indexing
   * @return bool
   */
  public function is_indexing(): bool {

    global $is_indexing;
    if ( isset( $is_indexing ) ) {
      new Log( 'Get is_indexing from global', 'debug', array( 'is_indexing' => (bool) $is_indexing ) );
      return (bool) $is_indexing;
    }

    global $wpdb;
    $is_indexing = $wpdb->get_var( "SELECT option_value FROM $wpdb->options WHERE option_name = 'findstr_is_indexing'" );

    return (bool) $is_indexing;
  }




  /**
   * Determine witch documents should be deleted.
   *
   * This is done by comparing the last indexed ids with the new ones.
   *
   * @param $posts_ids_to_index
   *
   * @return array
   */
  public function get_post_ids_to_delete( $posts_ids_to_index ): array {

    $last_indexed_ids = get_option( 'findstr_last_indexed_ids' );
    if ( empty( $last_indexed_ids ) ) {
      return array();
    }

    $this->ids_to_delete = array_values( array_diff( $last_indexed_ids, $posts_ids_to_index ) );

    return $this->ids_to_delete;

  }


  public function status(): array {

    $stats = array(
      'numberOfDocuments' => 0,
      'isIndexing'        => false,
    );

    try {
      $index           = $this->get_index();
      $stats           = $index->stats();
      $documents       = $index->getDocuments();
      $sever_connected = true;
    } catch ( \Exception $e ) {
      new Log( 'Index not found', 'error', array( 'message' => $e->getMessage() ) );
      $sever_connected = false;
    }

    $findstr_last_indexed_ids = count( get_option( 'findstr_last_indexed_ids', array() ) );
    $total_documents_to_index = $findstr_last_indexed_ids;

    $documents_in_queue = $this->count_remaining_posts();

    $total_documents_to_delete = count( get_option( 'findstr_last_deleted_ids', array() ) );
    $total_documents_indexed   = $total_documents_to_index - $documents_in_queue;

    $percentage = round( $total_documents_to_index ? ( ( $total_documents_indexed ) / $total_documents_to_index ) * 100 : 100, 2 );
    $percentage = min( $percentage, 100 );

    return array(
      'total_documents_to_index'  => $total_documents_to_index,
      'findstr_last_indexed_ids'  => $findstr_last_indexed_ids,
      'total_documents_to_delete' => $total_documents_to_delete,
      'total_documents_indexed'   => isset( $documents ) ? $documents->getTotal() : 0,
      'is_indexing'               => $this->is_indexing(),
      'server_is_indexing'        => ! empty( $stats['isIndexing'] ),
      'server_is_connected'       => $sever_connected,
      'pending_documents'         => max( $documents_in_queue, 0 ),
      'documents_in_queue'        => max( $documents_in_queue, 0 ),
      'progression'               => max( $percentage, 0 ),
      'last_status_update'        => time(),
      'indexing_start_time'       => get_option( 'findstr_current_index_key', false ),
    );

  }

  /**
   * Index documents in batch.
   *
   * This is because Meilisearch can handle a lot of queries !
   *
   * @param array $ids
   * @param string $lang
   *
   * @return void
   */
  public function batch_index( array $ids = array(), string $lang = '' ) {

    $start_time = microtime( true );
    $this->set_indexing_status( true );

    if ( empty( $lang ) ) {
      _doing_it_wrong( __FUNCTION__, 'The language parameter is required', '0.5.0' );
      new Log( 'Batch index error', 'error', array( 'message' => 'The language parameter is required in batch_index function' ) );
    }

    if ( empty( $ids ) ) {
      new Log(
        'Batch index error',
        'error',
        array(
          'message' => 'The ids parameter is empty',
        )
      );
      return;
    }

    //check if $ids is an array of integers
    $array_of_ids = array_filter( $ids, 'is_numeric' ) === $ids;
    if ( ! $array_of_ids ) {
      new Log(
        'Batch index error',
        'error',
        array(
          'message' => 'The ids parameter must be an array of integers',
        )
      );
      return;
    }

    new Log( sprintf( 'Batch index of %s documents received, language %s', count( $ids ), $lang ), 'debug', array() );

    /**
     * Use this filter to index documents through http call.
     * This is useful when you have some translations in constant and you want to index them in the right language.
     *
     * @hook findstr_indexer_use_network
     *
     * @returns {bool} $use_network default to false
     */
    $use_network = apply_filters( 'findstr_indexer_use_network', false );

    if ( false !== $use_network ) {
      //post ids to index through http call, to ensure that we use the right language
      $path = get_rest_url( null, 'findstr/v1/index?findstr-language=' . $lang . '&lang=' . $lang );
      new Log( sprintf( 'Call api %s', $path ), 'debug', array() );

      //create a hash and set it in transient
      $nonce = 'findstr_' . wp_hash( time() . '_findstr_action' );
      set_transient( $nonce, $nonce, 10 * MINUTE_IN_SECONDS );

      $call = wp_remote_post(
        $path,
        array(
          'body'        => array(
            'ids'   => $ids,
            'nonce' => $nonce,
          ),
          'nonblocking' => true,
          'sslverify'   => false,
          'timeout'     => FINDSTR_BATCH_CALL_TIMEOUT,
        )
      );

      if ( is_wp_error( $call ) ) {
        new Log( 'Batch index error', 'error', array( 'message' => $call->get_error_message() ) );
      }
    } else {

      //switch to the right language
      new Log( sprintf( 'Switch to language %s', $lang ), 'debug', array() );
      Helpers::switch_language( $lang );

      $this->index( $ids );
      $this->write_status_to_file();
    }

    $end_time       = microtime( true );
    $execution_time = round( $end_time - $start_time, 2 );
    new Log( sprintf( 'Batch index done in %s seconds', $execution_time ), 'debug', array() );

  }

  /**
   * Get sortable attributes.
   * This is used to determine which attributes can be used to sort the results.
   *
   * @return array
   */
  public function get_sortable_attributes(): array {

    $indexable_fields = (array) ( new SettingsIndexableFields() )->get();

    //get all sortable fields based on indexable fields properties
    $sortable_attributes = array_filter(
      $indexable_fields,
      function ( $field ) {
        return isset( $field->sortable ) && $field->sortable;
      }
    );

    //keep key only
    $sortable_attributes = array_keys( $sortable_attributes );

    foreach ( $sortable_attributes as $sortable_attribute ) {
      $sortable_attribute          = explode( '/', $sortable_attribute );
      $this->sortable_attributes[] = end( $sortable_attribute );
    }

    return array_values(
      array_unique(
        /**
         *  Use this filter to modify the sortable attributes.
         *
         * @hook findstr_sortable_attributes
         *
         * @param {array} $sortable_attributes
         *
         * @return {array} $sortable_attributes
         */
        apply_filters(
          'findstr_sortable_attributes',
          array_merge(
            array_unique( $this->sortable_attributes ),
            array(
              'post_title',
              'post_date',
              'menu_order',
              'sticky',
            )
          )
        )
      )
    );
  }

  /**
   * Get filterable attributes.
   * This is used to determine which attributes can be used to filter the results.
   *
   * @return array
   */
  public function get_filterable_attributes(): array {

    $indexable_fields = (array) ( new SettingsIndexableFields() )->get();

    $filterable_attributes = array_filter(
      $indexable_fields,
      function ( $field ) {
        return isset( $field->filterable ) && $field->filterable;
      }
    );

    $filterable_attributes = array_keys( $filterable_attributes );

    foreach ( $filterable_attributes as $filterable_attribute ) {
      $filterable_attribute          = explode( '/', $filterable_attribute );
      $this->filterable_attributes[] = end( $filterable_attribute );

      /**
       * Use this filter to modify each filterable attribute before adding it.
       *
       * @hook findstr_each_filterable_attribute
       *
       * @param string $filterable_attribute The filterable attribute being processed.
       *
       * @return string The modified filterable attribute.
       */
      $this->filterable_attributes = apply_filters( 'findstr_each_filterable_attribute', $this->filterable_attributes, $filterable_attribute );
    }

    return array_values(
      array_unique(
      /**
       * Use this filter to modify the filterable attributes.
       *
       * @hook findstr_filterable_attributes
       *
       * @param {array} $filterable_attributes
       *
       * @return {array} $filterable_attributes
       */
        apply_filters(
          'findstr_filterable_attributes',
          array_merge(
            array_unique( $this->filterable_attributes ),
            array(
              'language',
              'post_type',
              'post_author',
              'findstr_key',
            )
          )
        )
      )
    );
  }

  public function get_ranking_rules() {

    /**
     * Use this filter to modify the Meilisearch ranking rules.
     * @see https://www.meilisearch.com/docs/learn/core_concepts/relevancy#ranking-rules
     *
     * @hook findstr_ranking_rules
     *
     * @param {array} $ranking_rules
     *
     * @return {array} $ranking_rules
     */
    return apply_filters(
      'findstr_ranking_rules',
      array(
        'words',
        'typo',
        'proximity',
        'attribute',
        'weight:desc',
        'sort',
        'exactness',
      )
    );
  }

  /**
   * This function checks if the indexing process is done.
   *
   * @return bool
   */
  public function is_indexer_done(): bool {

    if ( ! as_get_scheduled_actions(
      array(
        'hook'   => 'findstr_index_documents',
        'group'  => 'findstr_index_batch',
        'status' => \ActionScheduler_Store::STATUS_PENDING,
      )
    ) ) {
      return true;
    }

    return false;
  }


  public function count_remaining_posts(): int {

    $tasks = as_get_scheduled_actions(
      array(
        'hook'     => 'findstr_index_documents',
        'group'    => 'findstr_index_batch',
        'per_page' => 1000,
        'status'   => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ),
      )
    );

    $count = 0;

    foreach ( $tasks as $task ) {
      //set type of $task to ActionScheduler_Action
      $args   = $task->get_args();
      $count += (int) count( $args[0] );
    }

    return $count;
  }

  /**
   * Shutdown function
   *
   * @return void
   */
  public function indexer_shutdown() {

    //check if we are indexing
    if ( empty( $this->is_indexing() ) ) {
      return;
    }

    //check when the indexing process was started
    $indexing_start_time = get_option( 'findstr_current_index_key' );

    if ( false === $indexing_start_time ) {
      return;
    }

    //if indexing was started less than 30 seconds ago, we are not indexing
    if ( ( strtotime( current_time( 'mysql' ) ) - strtotime( $indexing_start_time ) ) < 30 ) {
      new Log( 'Indexing was started less than 30s ago... (shutdown)', 'info', array() );
      return;
    }

    $status = $this->status();
    $this->write_status_to_file( $status );

    //attempt to finish the indexing process
    if ( $this->is_indexer_done() && $status['is_indexing'] && (int) $status['pending_documents'] <= 0 ) {
      new Log( 'Indexing is done, but work is not finished yet...', 'info', array() );
      $this->after_index_actions();
    }

    /**
     * check is indexing is stuck
     */
    $timeout          = apply_filters( 'findstr_indexing_timeout', 30 * MINUTE_IN_SECONDS );
    $index_started_at = $status['indexing_start_time'];

    if ( false === $index_started_at ) {
      return;
    }

    $index_started_at_timestamp = strtotime( $index_started_at );

    if ( $index_started_at_timestamp && ( strtotime( current_time( 'mysql' ) ) - $index_started_at_timestamp ) > $timeout ) {
      new Log( 'Indexing got stuck after ' . $timeout . ' seconds. Abort...', 'error', array() );
      $this->set_indexing_status( false );
    }

  }


  public function stop_indexing(): void {

    new Log( 'Stop indexing...', 'info', array() );

    $status = $this->status();

    $status['is_indexing']       = false;
    $status['pending_documents'] = 0;
    $status['progression']       = 100;

    $this->write_status_to_file( $status );

    //remove all pending actions
    as_unschedule_all_actions( 'findstr_index_documents' );

    $this->set_indexing_status( false );
  }

  /**
   * function to clean index from deleted documents
   */
  public function clean_deleted_posts() {

    new Log( 'Clean index', 'info', array() );

    $index = $this->get_index();

    $post_type_to_index = implode( ', ', $this->get_post_types_to_index() );

    $document_key = get_option( 'findstr_current_index_key' );

    $remaining_id = $index->search(
      '',
      array(
        'attributesToRetrieve' => array( 'ID' ),
        'filter'               => "findstr_key EXISTS AND findstr_key != '$document_key' AND post_type IN [$post_type_to_index]",
      )
    );

    $ids = array_map(
      function ( $item ) {
        return $item['ID'];
      },
      $remaining_id->getHits()
    );

    if ( ! empty( $ids ) ) {
      $this->batch_delete( $ids );
    }

  }


  /**
   * Actions to run at the end of the indexing process
   *
   * @return void
   */
  public function after_index_actions() {

    new Log( 'Fire post index actions...', 'debug', array() );

    $index = $this->get_index();

    /**
     * This action is fired after the indexing process is done.
     *
     * @hook findstr_after_index
     *
     * @param {array} $args with key index, the meilisearch index
     *
     */
    do_action( 'findstr_after_index', array( 'index' => $index ) );

    //Sortable attributes
    new Log( 'Set sortable attributes...', 'debug', $this->get_sortable_attributes() );
    $index->updateSortableAttributes( $this->get_sortable_attributes() );

    //Filterable attributes
    new Log( 'Set filterable attributes...', 'debug', $this->get_filterable_attributes() );
    $index->updateFilterableAttributes( $this->get_filterable_attributes() );

    //set ranking rules
    new Log( 'Set ranking rules...', 'debug', $this->get_ranking_rules() );
    $index->updateRankingRules( $this->get_ranking_rules() );

    //cleanup transients
    $this->cleanup_transients();

    //update status
    $this->set_indexing_status( false );
    $this->write_status_to_file();

    //check how log it took to index
    $indexing_start_time = get_option( 'findstr_current_index_key' );
    $indexing_start_time = strtotime( $indexing_start_time );
    $indexing_end_time   = strtotime( current_time( 'mysql' ) );
    $indexing_duration   = $indexing_end_time - $indexing_start_time;

    new Log(
      sprintf(
        'Indexing took %d m %d s',
        (int) floor( $indexing_duration / 60 ) % 60,
        (int) floor( $indexing_duration % 60 )
      ),
      'info',
      array()
    );
    new Log( '... indexing if finish, bye !', 'info', array() );
  }


  /**
   * This function is called after the plugin is updated.
   *
   * @return void
   */
  public function update_index_attributes(): void {

    new Log( 'Update index attributes', 'info', array() );

    try {
      $index = $this->get_index();

      //Sortable attributes
      new Log( 'Set sortable attributes.', 'debug', $this->get_sortable_attributes() );
      $index->updateSortableAttributes( $this->get_sortable_attributes() );

      //Filterable attributes
      new Log( 'Set filterable attributes.', 'debug', $this->get_filterable_attributes() );
      $index->updateFilterableAttributes( $this->get_filterable_attributes() );

      //set ranking rules
      new Log( 'Set ranking rules.', 'debug', $this->get_ranking_rules() );
      $index->updateRankingRules( $this->get_ranking_rules() );

    } catch ( \Exception $e ) {
      new Log( 'Update index attributes error', 'error', array( 'message' => $e->getMessage() ) );
    }

    new Log( 'Index attributes updated', 'info', array() );
  }

  /**
   * Cleanup transients from indexing process
   *
   * @return void
   */
  private function cleanup_transients(): void {

    //find all transients from database
    //then delete them with WordPress native functions
    global $wpdb;
    $transients = $wpdb->get_results( "SELECT option_value FROM $wpdb->options WHERE option_name LIKE '_transient_findstr_%' AND option_value LIKE 'findstr_%'" );
    foreach ( $transients as $transient ) {
      delete_transient( $transient->option_value );
    }
  }


  /**
   * Clear index
   *
   * @return void
   */
  public function clear_index() {

    new Log( 'Clear index', 'info', array() );

    update_option( 'findstr_current_indexed_ids', array(), false );
    update_option( 'findstr_last_deleted_ids', array(), false );
    update_option( 'findstr_last_indexed_ids', array(), false );

    $this->get_index()->deleteAllDocuments();
  }

}