load_settings( $post ); $this->schedule_cron_event(); } /** * Load importer settings from post object */ protected function load_settings( $post ) { $infos = array( 'id' => $post->ID, 'slug' => $post->post_name, 'title' => $post->post_title, 'modified' => $post->post_modified_gmt, ); $this->infos = new Air_WP_Sync_Importer_Settings( $infos ); $config = json_decode( $post->post_content, true ); $this->config = new Air_WP_Sync_Importer_Settings( $config ); } /** * Get or instanciate Airtable API client */ protected function get_api_client() { if ( null === $this->api_client ) { $this->api_client = new Air_WP_Sync_Airtable_Api_Client( $this->config()->get( 'app_id' ), $this->config()->get( 'api_key' ) ); } return $this->api_client; } /** * Init cron events */ protected function schedule_cron_event() { if ( 'cron' === $this->config()->get( 'scheduled_sync.type' ) && $this->config()->get( 'scheduled_sync.recurrence' ) ) { add_action( $this->get_schedule_slug(), array( $this, 'cron' ) ); if ( false === $this->get_next_scheduled_sync() ) { wp_schedule_event( time() + HOUR_IN_SECONDS, $this->config()->get( 'scheduled_sync.recurrence' ), $this->get_schedule_slug() ); } } else { if ( $this->get_next_scheduled_sync() ) { wp_clear_scheduled_hook( $this->get_schedule_slug() ); } } } /** * Infos getter */ public function infos() { return $this->infos; } /** * Config getter */ public function config() { return $this->config; } /** * Scheduled Sync next getter */ public function get_next_scheduled_sync() { return wp_next_scheduled( $this->get_schedule_slug() ); } /** * Get cron schedule slug */ protected function get_schedule_slug() { return 'air_wp_sync_importer_' . $this->infos()->get( 'id' ); } /** * Lock process to avoid concurrent runs */ public function lock_process() { $this->lock_file = fopen( trailingslashit( wp_upload_dir()['basedir'] ) . $this->get_schedule_slug() . '.pid', 'c' ); $got_lock = flock( $this->lock_file, LOCK_EX | LOCK_NB ); if ( $got_lock ) { ftruncate( $this->lock_file, 0 ); fwrite( $this->lock_file, getmypid() . "\n" ); } return $got_lock; } /** * Unlock process */ public function unlock_process() { ftruncate( $this->lock_file, 0 ); flock( $this->lock_file, LOCK_UN ); } /** * Cron action */ public function cron() { $this->run(); } /** * Run importer */ public function run() { $this->log( sprintf( 'Starting importer...' ) ); $offset = null; $post_ids = array(); try { if ( ! $this->lock_process() ) { throw new Exception( 'Another instance is already running; terminating.' ); } // Loop through all pages do { $return = $this->get_records( $offset ); $offset = $return['offset']; $post_ids = array_unique( array_merge( $post_ids, $return['post_ids'] ) ); } while ( ! empty( $offset ) ); // Delete removed posts if ( 'add_update_delete' === $this->config()->get( 'sync_strategy' ) ) { $this->delete_removed_posts( $post_ids ); } // Save successful update data update_post_meta( $this->infos()->get( 'id' ), 'last_updated', gmdate( 'Y-m-d H:i:s' ) ); update_post_meta( $this->infos()->get( 'id' ), 'status', 'success' ); return true; } catch ( Exception $e ) { update_post_meta( $this->infos()->get( 'id' ), 'last_error', $e->getMessage() ); update_post_meta( $this->infos()->get( 'id' ), 'status', 'error' ); $this->log( $e->getMessage() ); return new WP_Error( 'air-wp-sync-run-error', $e->getMessage() ); } catch ( TypeError $e ) { update_post_meta( $this->infos()->get( 'id' ), 'last_error', $e->getMessage() ); update_post_meta( $this->infos()->get( 'id' ), 'status', 'error' ); $this->log( $e->getMessage() ); return new WP_Error( 'air-wp-sync-run-error', $e->getMessage() ); } } /** * Get AT records from API */ public function get_records( $offset = null ) { $data = $this->get_api_client()->list( $this->config()->get( 'table' ), array( 'offset' => $offset ) ); // Loop through all records $post_ids = array(); $updated_post_ids = array(); foreach ( $data->records as $record ) { $this->log( sprintf( 'Record ID %s', $record->id ) ); // Check if we have existing post for this record $post_id = $this->get_object_id_from_record_id( $record->id, $this->config()->get( 'post_type' ) ); if ( $post_id ) { $this->log( sprintf( '- Found matching post, ID %s', $post_id ) ); $post_ids[] = $post_id; // Check if we must update it if ( 'add' !== $this->config()->get( 'sync_strategy' ) && $this->needs_update( $post_id, $record ) ) { $post_id = $this->import_record( $record, $post_id ); if ( ! empty( $post_id ) && ! is_wp_error( $post_id ) ) { $updated_post_ids[] = $post_id; } } else { $this->log( sprintf( '- No update needed' ) ); } } else { $post_id = $this->import_record( $record ); $post_ids[] = $post_id; $updated_post_ids[] = $post_id; } } do_action( 'airwpsync/import_records_after', $post_ids, $updated_post_ids, $this->config()->get( 'table' ), $data ); $offset = ! empty( $data->offset ) ? $data->offset : false; return compact( 'offset', 'post_ids', 'updated_post_ids' ); } /** * Delete other posts existing in WP but deleted in AT */ public function delete_removed_posts( $post_ids ) { $posts = get_posts( array( 'post_type' => $this->config()->get( 'post_type' ), 'post_status' => 'any', 'post__not_in' => $post_ids, 'fields' => 'ids', 'posts_per_page' => -1, 'meta_query' => array( array( 'key' => '_air_wp_sync_record_id', 'compare' => 'EXISTS', ), ), ) ); foreach ( $posts as $post_id ) { wp_delete_post( $post_id, true ); } } /** * Import AT record */ protected function import_record( $record, $post_id = null ) { $this->log( sprintf( $post_id ? '- Update record %s' : '- Create record %s', $record->id ) ); $record = apply_filters( 'airwpsync/import_record_data', $record, $this ); // Convert fields to array $fields = json_decode( wp_json_encode( $record->fields ), true ); // Keep mapped fields $airtable_keys = array_map( function( $mapping ) { return $mapping['airtable']; }, ! empty( $this->config()->get( 'mapping' ) ) ? $this->config()->get( 'mapping' ) : array() ); $fields = array_filter( $fields, function( $key ) use ( $airtable_keys ) { return in_array( $key, $airtable_keys, true ); }, ARRAY_FILTER_USE_KEY ); $fields = apply_filters( 'airwpsync/import_record_fields', $fields, $this ); $post_data = array( 'post_type' => $this->config()->get( 'post_type' ), 'post_author' => $this->config()->get( 'post_author' ), 'post_status' => $this->config()->get( 'post_status' ), 'post_title' => 'Airtable Imported Content', ); $post_metas = array( '_air_wp_sync_record_id' => $record->id, '_air_wp_sync_hash' => $this->generate_hash( $record ), '_air_wp_sync_updated_at' => gmdate( 'Y-m-d H:i:s' ), ); $post_data = apply_filters( 'airwpsync/import_post_data', $post_data, $this, $fields, $record, $post_id ); // Insert or update post if ( $post_id ) { wp_update_post( array_merge( array( 'ID' => $post_id ), $post_data ) ); } else { $post_id = wp_insert_post( $post_data ); } // Handle metas foreach ( $post_metas as $meta_key => $meta_value ) { update_post_meta( $post_id, $meta_key, $meta_value ); } do_action( 'airwpsync/import_record_after', $this, $fields, $record, $post_id ); // Force wp_insert_post retrigger after metas do_action( 'wp_insert_post', $post_id, get_post( $post_id ), true ); return $post_id; } /** * Get WP object id from AT record id */ protected function get_object_id_from_record_id( $record_id, $post_type ) { $objects = get_posts( array( 'fields' => 'ids', 'post_type' => $post_type, 'post_status' => 'any', 'meta_query' => array( array( 'key' => '_air_wp_sync_record_id', 'value' => $record_id, ), ), ) ); return array_shift( $objects ); } /** * Compare hashes to check if WP object needs update */ protected function needs_update( $post_id, $record ) { return $this->generate_hash( $record ) !== $this->get_post_hash( $post_id ); } /** * Get stored post hash */ protected function get_post_hash( $post_id ) { return get_post_meta( $post_id, '_air_wp_sync_hash', true ); } /** * Generate hash for given Airtable record */ protected function generate_hash( $record ) { // Remove cs & ts query strings from urls in AT record $record_json = preg_replace( '/cs=[^"]+/i', '', wp_json_encode( $record ) ); $record_json = preg_replace( '/ts=[^"]+/i', '', $record_json ); return md5( $record_json . wp_json_encode( $this->config()->to_array() ) ); } /** * Log message to file and WPCLI output */ public function log( $message, $level = 'log' ) { if ( ! is_dir( AIR_WP_SYNC_LOGDIR ) ) { wp_mkdir_p( AIR_WP_SYNC_LOGDIR ); } $file = fopen( AIR_WP_SYNC_LOGDIR . '/' . $this->infos()->get( 'slug' ) . '-' . gmdate( 'Y-m-d' ) . '-' . md5( getmypid() ) . '.log', 'a' ); if ( ! is_string( $message ) ) { $message = var_export( $message, true ); } fwrite( $file, "\n" . gmdate( 'Y-m-d H:i:s' ) . ' ' . $level . ' :: ' . $message ); fclose( $file ); if ( class_exists( 'WP_CLI' ) ) { $method = method_exists( 'WP_CLI', $level ) ? $level : 'log'; WP_CLI::$method( $message ); } } }