plugin = $plugin; } /** * Registers the handlers. */ public function register(): void { add_action( 'admin_post_' . self::BULK, array( $this, 'bulk' ) ); add_action( 'admin_post_' . self::DO_ONE, array( $this, 'do_one' ) ); add_action( 'admin_post_' . self::BATCH_STOP, array( $this, 'cancel_batch' ) ); add_action( 'admin_post_' . self::LINK, array( $this, 'link_adverts' ) ); add_action( 'admin_post_' . self::STATUS_ALL, array( $this, 'status_all' ) ); add_action( 'admin_post_' . self::PHOTOS, array( $this, 'save_photos' ) ); add_action( 'admin_post_' . self::PRODUCT_CATEGORY, array( $this, 'save_product_category' ) ); add_action( 'admin_post_' . self::EXPORT_CSV, array( $this, 'export_csv' ) ); add_action( 'wp_ajax_' . self::AJAX_PREVIEW, array( $this, 'ajax_preview' ) ); } /** * Items from a key such as "12:0" (product ID : variation ID). * * @param mixed $keys Posted keys. * @return array */ public static function parse_items( $keys ): array { $out = array(); foreach ( (array) $keys as $key ) { if ( preg_match( '/^(\d+):(\d+)$/', (string) $key, $m ) && (int) $m[1] > 0 ) { $out[ $m[1] . ':' . $m[2] ] = array( (int) $m[1], (int) $m[2] > 0 ? (int) $m[2] : null ); } } return array_values( $out ); } /** * The bulk action form. */ public function bulk(): void { $this->guard( self::BULK, false, self::BULK_NONCE_FIELD ); // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce checked in guard(); values are sanitized by parse_items() and below. $do = isset( $_POST['bulk'] ) ? sanitize_key( wp_unslash( $_POST['bulk'] ) ) : ''; $items = self::parse_items( isset( $_POST['item'] ) ? wp_unslash( $_POST['item'] ) : array() ); $confirm = isset( $_POST['confirm'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['confirm'] ) ) ) : ''; // phpcs:enable if ( '' === $do ) { Flash::add( 'error', __( 'Choose an action first.', 'tidelist' ) ); } elseif ( ! $items ) { Flash::add( 'error', __( 'Nothing was done: no product was ticked.', 'tidelist' ) ); } else { $this->run( $do, $items, $confirm ); } $this->back(); } /** * One action on one item (a link in the table). */ public function do_one(): void { $this->guard( self::DO_ONE, true ); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce checked in guard(). $do = isset( $_GET['do'] ) ? sanitize_key( wp_unslash( $_GET['do'] ) ) : ''; $items = self::parse_items( isset( $_GET['item'] ) ? array( sanitize_text_field( wp_unslash( $_GET['item'] ) ) ) : array() ); // phpcs:enable if ( $do && 'publish' !== $do && $items ) { $this->run( $do, $items, '' ); } $this->back(); } /** * Runs one action on items. * * @param string $operation Action. * @param array $items Items. * @param string $confirm Typed confirmation (for publishing). */ private function run( string $operation, array $items, string $confirm ): void { $channel_label = $this->plugin->channel()->label(); $service = $this->plugin->listing_service(); switch ( $operation ) { case 'select': $changed = 0; $skipped = 0; foreach ( $items as $item ) { if ( $this->cannot_be_advertised( $item ) ) { ++$skipped; continue; } $changed += $service->queue( $item[0], $item[1] )['changed'] ? 1 : 0; } /* translators: %d: number of products */ Flash::add( 'success', sprintf( __( '%d product(s) selected for publishing. Nothing is sent yet.', 'tidelist' ), $changed ) ); $this->flash_skipped( $skipped ); break; case 'unselect': $changed = 0; foreach ( $items as $item ) { $row = $service->find( $item[0], $item[1] ); if ( null !== $row && $service->unqueue( (int) $row['id'] ) ) { ++$changed; } } /* translators: %d: number of products */ Flash::add( 'success', sprintf( __( '%d product(s) unselected.', 'tidelist' ), $changed ) ); break; case 'publish': $this->publish( $items, $confirm ); break; case 'update': $this->each_listing( $items, array( $this, 'update_one' ), Text::channel( __( 'Updated on {channel}', 'tidelist' ), $channel_label ) ); break; case 'status': $this->each_listing( $items, array( $this, 'status_one' ), Text::channel( __( 'Status read from {channel}', 'tidelist' ), $channel_label ) ); break; case 'deactivate': $this->each_listing( $items, array( $this, 'deactivate_one' ), Text::channel( __( 'Taken down from {channel}', 'tidelist' ), $channel_label ) ); break; case 'relist': $this->each_listing( $items, array( $this, 'relist_one' ), __( 'Put back online', 'tidelist' ) ); break; default: Flash::add( 'error', __( 'Unknown action.', 'tidelist' ) ); } } /** * Runs a callback on the listing of each item and reports the totals. * * @param array $items Items. * @param callable $callback callable( array $row ): string — one of 'done', 'unchanged', 'dry_run', 'failed'. * @param string $label What "done" means. */ private function each_listing( array $items, callable $callback, string $label ): void { $totals = array( 'done' => 0, 'unchanged' => 0, 'dry_run' => 0, 'failed' => 0, ); $seen = 0; foreach ( $items as $item ) { $row = $this->plugin->listing_service()->find( $item[0], $item[1] ); if ( null === $row || '' === (string) $row['remote_id'] ) { ++$totals['unchanged']; continue; } if ( ++$seen > self::MAX_ACTIONS ) { Flash::add( 'warning', sprintf( /* translators: %d: limit */ __( 'Only the first %d products were handled. Do the rest in another round.', 'tidelist' ), self::MAX_ACTIONS ) ); break; } ++$totals[ call_user_func( $callback, $row ) ]; } $message = sprintf( /* translators: 1: what was done, 2: number done, 3: not needed or not possible, 4: dry-run, 5: failed */ __( '%1$s: %2$d. No change needed or nothing to do: %3$d. Dry-run (nothing sent): %4$d. Failed (see the log): %5$d.', 'tidelist' ), $label, $totals['done'], $totals['unchanged'], $totals['dry_run'], $totals['failed'] ); Flash::add( $totals['failed'] ? 'warning' : 'success', $message ); } /** * Updates one advert now (the owner pressed the button, so this works without the Pro add-on). * * @param array $row Listing row. */ private function update_one( array $row ): string { $result = ( new UpdateListing( $this->plugin ) )->worker( (int) $row['id'] )->check( (int) $row['id'] ); switch ( $result->action ) { case UpdateResult::UPDATED: return 'done'; case UpdateResult::SIMULATED: return 'dry_run'; case UpdateResult::UNCHANGED: case UpdateResult::NOT_LISTED: return 'unchanged'; } return 'failed'; } /** * Reads one advert's status. * * @param array $row Listing row. */ private function status_one( array $row ): string { $poller = new StatusPoller( $this->plugin->listings(), $this->plugin->channel_of( (int) $row['id'] ), $this->plugin->logger() ); return null === $poller->poll( (int) $row['id'] ) ? 'failed' : 'done'; } /** * Takes one advert down (never deletes it). * * @param array $row Listing row. */ private function deactivate_one( array $row ): string { if ( ! in_array( $row['status'], ListingStatus::on_marketplace(), true ) ) { return 'unchanged'; } try { $outcome = $this->plugin->channel_of( (int) $row['id'] )->deactivate( (string) $row['remote_id'], (string) $row['remote_status'], false ); } catch ( ChannelException $e ) { $text = ErrorExplainer::for_channel( $this->plugin->channel_of( (int) $row['id'] ) )->explain_exception( $e ); $this->plugin->logger()->log( Logger::ERROR, 'takedown', sprintf( 'Taking down listing %d failed: %s', (int) $row['id'], $text ) ); $this->plugin->listings()->update( (int) $row['id'], array( 'last_error' => $text ) ); return 'failed'; } if ( $outcome->simulated ) { return 'dry_run'; } $fields = $outcome->fields(); $fields['last_synced_at'] = gmdate( 'Y-m-d H:i:s' ); $fields['last_error'] = ''; $this->plugin->listings()->update( (int) $row['id'], $fields ); $this->plugin->logger()->log( Logger::INFO, 'takedown', sprintf( 'Listing %d taken down by the owner.', (int) $row['id'] ) ); return 'done'; } /** * Puts one taken-down advert back online. * * @param array $row Listing row. */ private function relist_one( array $row ): string { switch ( ( new Relist( $this->plugin ) )->worker( (int) $row['id'] )->relist( (int) $row['id'] ) ) { case RelistWorker::RELISTED: return 'done'; case RelistWorker::SIMULATED: return 'dry_run'; case RelistWorker::NOT_INACTIVE: case RelistWorker::NOT_SELLING: return 'unchanged'; } return 'failed'; } /** * Starts a hand-picked batch: only the ticked adverts, after the owner typed PUBLISH. * * @param array $items Items. * @param string $confirm Typed confirmation. */ private function publish( array $items, string $confirm ): void { if ( self::PUBLISH_PHRASE !== $confirm ) { Flash::add( 'error', __( 'Nothing was sent. To publish, type PUBLISH in the confirmation box.', 'tidelist' ) ); } elseif ( count( $items ) > $this->plugin->channel()->limits()->batch_max ) { /* translators: %d: largest batch */ Flash::add( 'error', sprintf( __( 'Nothing was sent: at most %d adverts can go out in one batch.', 'tidelist' ), $this->plugin->channel()->limits()->batch_max ) ); } elseif ( $this->plugin->settings()->is_paused() ) { Flash::add( 'error', __( 'Nothing was sent: the plugin is paused.', 'tidelist' ) ); } elseif ( ! PublishBatch::can_schedule() ) { Flash::add( 'error', __( 'Nothing was sent: the background job system (Action Scheduler) is not available.', 'tidelist' ) ); } else { $current = PublishBatch::current(); if ( null !== $current && $current->is_running() ) { Flash::add( 'error', __( 'Nothing was started: another batch is still running. Wait for it or cancel it first.', 'tidelist' ) ); } else { $this->start_batch( $items ); } } } /** * Selects the items, checks each advert again and starts the batch with those that can be published. * * @param array $items Items. */ private function start_batch( array $items ): void { $service = $this->plugin->listing_service(); $valid = array(); $skipped = 0; foreach ( $items as $item ) { if ( $this->cannot_be_advertised( $item ) ) { ++$skipped; continue; } $queued = $service->queue( $item[0], $item[1] ); $row = $this->plugin->listings()->find( $queued['id'] ); if ( null === $row || ListingStatus::QUEUED !== $row['status'] || '' !== (string) $row['remote_id'] ) { continue; } $result = $this->plugin->build_advert( $row ); if ( null !== $result && $result->is_ready() ) { $valid[] = (int) $row['id']; } } if ( ! $valid ) { Flash::add( 'error', __( 'Nothing was sent: none of the ticked adverts can be published. Open the preview to see what is missing.', 'tidelist' ) ); $this->flash_skipped( $skipped ); return; } ( new PublishBatch( $this->plugin ) )->start( $valid ); $message = sprintf( /* translators: 1: number of adverts, 2: seconds between adverts */ __( 'Batch started: %1$d advert(s) will go out one by one, about %2$d seconds apart, in the background. Progress is shown above; this page refreshes by itself.', 'tidelist' ), count( $valid ), PublishBatch::GAP_SECONDS ); if ( $this->plugin->settings()->is_dry_run() ) { $message .= ' ' . __( 'Dry-run is ON, so nothing is really sent.', 'tidelist' ); } Flash::add( count( $valid ) < count( $items ) ? 'warning' : 'success', $message ); $this->flash_skipped( $skipped ); } /** * Whether an item cannot be advertised now (out of stock or not published in the shop): publishing it would be a paid * placement for something the shop cannot sell. * * @param array $item [product ID, variation ID or null]. */ private function cannot_be_advertised( array $item ): bool { $facts = ( new \Tidelist\Woo\ItemFacts() )->read( array( 'product_id' => (int) $item[0], 'variation_id' => (int) $item[1], ) ); return '' !== \Tidelist\Sync\TakedownRules::listing_block( $facts ); } /** * Tells the owner how many products were left out because they cannot be advertised. * * @param int $skipped Number of products. */ private function flash_skipped( int $skipped ): void { if ( $skipped > 0 ) { /* translators: %d: number of products */ Flash::add( 'warning', sprintf( __( '%d product(s) were left out: they are out of stock or not published in the shop.', 'tidelist' ), $skipped ) ); } } /** * Cancels the running batch (adverts already sent stay at the marketplace). */ public function cancel_batch(): void { $channel_label = $this->plugin->channel()->label(); $this->guard( self::BATCH_STOP ); if ( PublishBatch::can_schedule() ) { ( new PublishBatch( $this->plugin ) )->cancel(); Flash::add( 'success', Text::channel( __( 'The batch was cancelled. Adverts already sent stay on {channel}.', 'tidelist' ), $channel_label ) ); } $this->back(); } /** * Finds the adverts that already exist on the marketplace and links them to shop items (read-only towards the marketplace). */ public function link_adverts(): void { $channel_label = $this->plugin->channel()->label(); $this->guard( self::LINK ); try { $linker = new AdvertLinker( $this->plugin->channel() ); $plan = AdvertLinker::plan( $linker->fetch_all(), $this->plugin->listings()->all(), $this->plugin->channel(), array( $this, 'resolve_external_id' ) ); } catch ( ChannelException $e ) { /* translators: %s: error */ Flash::add( 'error', sprintf( Text::channel( __( 'Could not read the adverts from {channel}: %s', 'tidelist' ), $channel_label ), $e->getMessage() ) ); $this->back(); } $linked = 0; foreach ( $plan['links'] as $link ) { if ( $this->plugin->listing_service()->link( $link['product_id'], $link['variation_id'], $link['fields'] ) ) { ++$linked; $this->plugin->logger()->log( Logger::INFO, 'link', sprintf( 'Item %d/%d linked to the existing advert %s.', $link['product_id'], (int) $link['variation_id'], (string) $link['fields']['remote_id'] ) ); } } $other = 0; foreach ( $plan['ignored'] as $item ) { if ( 'already_linked' !== $item['reason'] ) { ++$other; $this->plugin->logger()->log( Logger::WARNING, 'link', sprintf( 'Advert %d (external ID %s) was not linked: %s.', $item['advert_id'], $item['external_id'], $item['reason'] ) ); } } Flash::add( $other ? 'warning' : 'success', sprintf( /* translators: 1: adverts linked now, 2: adverts that were not linked (details in the log) */ Text::channel( __( 'Adverts found on {channel} and linked: %1$d. Not linked (see the log): %2$d.', 'tidelist' ), $channel_label ), $linked, $other ) ); $this->back(); } /** * Shop item for an external ID: [product ID, variation ID or null], or null. * * @param int $external_id Post ID. * @return array|null */ public function resolve_external_id( int $external_id ): ?array { $type = get_post_type( $external_id ); if ( 'product_variation' === $type ) { $parent = (int) wp_get_post_parent_id( $external_id ); return $parent ? array( $parent, $external_id ) : null; } if ( 'product' === $type ) { return array( $external_id, null ); } return null; } /** * The CSV of all listings (read-only; every cell is quoted and formulas are defused). */ public function csv(): string { $rows = $this->plugin->listings()->all(); $names = array(); foreach ( $rows as $row ) { $names[ (int) $row['id'] ] = (string) get_the_title( $row['variation_id'] ? (int) $row['variation_id'] : (int) $row['product_id'] ); } return ListingsCsv::build( $rows, $names ); } /** * Sends the CSV as a download. */ public function export_csv(): void { $this->guard( self::EXPORT_CSV ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="tidelist-listings-' . gmdate( 'Y-m-d' ) . '.csv"' ); echo $this->csv(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV file, every cell is quoted. exit; } /** * Reads the status of every advert (read-only; one GET per advert). */ public function status_all(): void { $channel_label = $this->plugin->channel()->label(); $this->guard( self::STATUS_ALL ); $ok = 0; $failed = 0; foreach ( $this->plugin->listings()->all() as $row ) { if ( '' === (string) $row['remote_id'] || ListingStatus::REMOVED === $row['status'] ) { continue; } $poller = new StatusPoller( $this->plugin->listings(), $this->plugin->channel_of( (int) $row['id'] ), $this->plugin->logger() ); if ( null === $poller->poll( (int) $row['id'] ) ) { ++$failed; } else { ++$ok; } } Flash::add( $failed ? 'warning' : 'success', sprintf( /* translators: 1: adverts checked, 2: adverts that could not be checked */ Text::channel( __( 'Status read from {channel} for %1$d advert(s); %2$d could not be checked (see the log).', 'tidelist' ), $channel_label ), $ok, $failed ) ); $this->back(); } /** * Saves which photos to send for a product. */ public function save_photos(): void { $this->guard( self::PHOTOS ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked in guard(). $items = self::parse_items( isset( $_POST['item'] ) ? array( sanitize_text_field( wp_unslash( $_POST['item'] ) ) ) : array() ); $keep = isset( $_POST['keep'] ) && is_array( $_POST['keep'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['keep'] ) ) : array(); // phpcs:enable if ( $items ) { $product_id = $items[0][0]; $settings = $this->plugin->settings(); $snapshot = ( new \Tidelist\Woo\ProductSnapshot() )->read( $product_id, $items[0][1] ); $all = null === $snapshot ? array() : $snapshot['all_images']; $list = (array) $settings->get( 'photo_exclusions' ); // The list is per product, shared by its variations: only this item's own photos are re-decided here. $others = array_diff( $this->plugin->excluded_photos_of( $product_id ), $all ); $skip = array_values( array_unique( array_merge( $others, array_diff( $all, $keep ) ) ) ); if ( $skip ) { $list[ $product_id ] = $skip; } else { unset( $list[ $product_id ] ); } $settings->update( array( 'photo_exclusions' => $list ) ); /* translators: %d: number of photos */ Flash::add( 'success', sprintf( __( 'Photo choice saved: %d photos will be sent for this product.', 'tidelist' ), count( array_intersect( $all, $keep ) ) ) ); } $this->back(); } /** * Chooses the marketplace category of one product, or takes the choice back (the category mapping decides again). */ public function save_product_category(): void { $this->guard( self::PRODUCT_CATEGORY ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked in guard(). $items = self::parse_items( isset( $_POST['item'] ) ? array( sanitize_text_field( wp_unslash( $_POST['item'] ) ) ) : array() ); $category = isset( $_POST['category'] ) ? absint( wp_unslash( $_POST['category'] ) ) : 0; $clear = ! empty( $_POST['clear'] ); // phpcs:enable if ( ! $items ) { $this->back(); } $product_id = $items[0][0]; $channel = $this->plugin->channel(); $channel_label = $channel->label(); $product = wc_get_product( $product_id ); $name = $product ? wp_strip_all_tags( $product->get_name() ) : '#' . $product_id; if ( $clear ) { $this->plugin->set_category_override( $product_id, 0 ); /* translators: %s: product name */ Flash::add( 'success', sprintf( __( '"%s" goes back to the category mapping.', 'tidelist' ), $name ) ); $this->back(); } $tree = $channel->taxonomy()->index(); if ( $category <= 0 || ! $tree->has( $category ) || ! $tree->is_leaf( $category ) ) { Flash::add( 'error', Text::channel( __( 'Nothing was changed: choose a category from the list ({channel} only takes the last level of its categories).', 'tidelist' ), $channel_label ) ); $this->back(); } $this->plugin->set_category_override( $product_id, $category ); /* translators: 1: product name, 2: marketplace category path */ Flash::add( 'success', sprintf( __( '"%1$s" now goes to %2$s.', 'tidelist' ), $name, $tree->path( $category ) ) ); $this->back(); } /** * The preview of one advert, as HTML for the dialog. */ public function ajax_preview(): void { if ( ! current_user_can( Plugin::CAPABILITY ) ) { wp_send_json_error( array( 'message' => 'forbidden' ), 403 ); } check_ajax_referer( self::AJAX_PREVIEW, '_ajax_nonce' ); $items = self::parse_items( isset( $_GET['item'] ) ? array( sanitize_text_field( wp_unslash( $_GET['item'] ) ) ) : array() ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked above. if ( ! $items ) { wp_send_json_error( array( 'message' => 'bad item' ), 400 ); } update_option( OnboardingPage::OPTION_PREVIEW, 1, false ); wp_send_json_success( array( 'html' => ( new ListingPreview( $this->plugin ) )->html( $items[0][0], $items[0][1] ) ) ); } /** * Capability and nonce check. * * @param string $action Nonce action. * @param bool $query Whether the nonce is in the address (a link) rather than in a form. * @param string $field Name of the field or parameter that holds the nonce. */ private function guard( string $action, bool $query = false, string $field = '_wpnonce' ): void { if ( ! current_user_can( Plugin::CAPABILITY ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'tidelist' ), '', array( 'response' => 403 ) ); } check_admin_referer( $action, $field ); } /** * Back to the Listings screen (or the page the action came from). The preview dialog is loaded by an AJAX request, * so its forms can name admin-ajax.php as the page they came from; that address shows raw data, never go there. */ private function back(): void { $referer = wp_get_referer(); if ( $referer && false !== strpos( $referer, 'admin-ajax.php' ) ) { $referer = false; } wp_safe_redirect( $referer ? $referer : Menu::listings_url() ); exit; } }