'item', 'plural' => 'items', 'ajax' => false, ) ); $this->plugin = $plugin; $this->categories = new CategoryResolver(); } /** * The current view (status tab). */ public function current_view(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a read-only filter. $view = isset( $_GET['view'] ) ? sanitize_key( wp_unslash( $_GET['view'] ) ) : self::VIEW_ALL; return in_array( $view, array_keys( self::views_list() ), true ) ? $view : self::VIEW_ALL; } /** * The tabs: view => label. * * @return array */ public static function views_list(): array { return array( self::VIEW_ALL => __( 'All', 'tidelist' ), self::VIEW_NOT_LISTED => __( 'Not listed', 'tidelist' ), ListingStatus::QUEUED => __( 'Selected', 'tidelist' ), ListingStatus::LIVE => __( 'Live', 'tidelist' ), ListingStatus::PENDING_REVIEW => __( 'Being checked', 'tidelist' ), ListingStatus::LIMITED => __( 'Waiting for payment', 'tidelist' ), ListingStatus::INACTIVE => __( 'Taken down', 'tidelist' ), ListingStatus::ERROR => __( 'Problems', 'tidelist' ), ); } /** * Tabs with counts. */ protected function get_views() { $counts = $this->plugin->listings()->count_by_status(); $current = $this->current_view(); $views = array(); foreach ( self::views_list() as $view => $label ) { $text = $label; if ( isset( $counts[ $view ] ) ) { $text .= ' (' . (int) $counts[ $view ] . ')'; } $views[ $view ] = sprintf( '%s', esc_url( add_query_arg( array( 'view' => $view, 'paged' => false, ), Menu::listings_url() ) ), $view === $current ? ' class="current" aria-current="page"' : '', wp_kses( $text, array( 'span' => array( 'class' => array() ) ) ) ); } return $views; } /** * Columns. */ public function get_columns() { $channel_label = $this->plugin->channel()->label(); return array( 'cb' => '', 'item' => __( 'Product', 'tidelist' ), 'category' => Text::channel( __( '{channel} category', 'tidelist' ), $channel_label ), 'price' => __( 'Price', 'tidelist' ), 'stock' => __( 'Stock', 'tidelist' ), 'status' => __( 'Status', 'tidelist' ), 'sync' => __( 'Last sync', 'tidelist' ), ); } /** * The bulk action menu is drawn by the page (it needs a field to type PUBLISH). * * @param string $which Position. */ protected function bulk_actions( $which = '' ) { unset( $which ); } /** * Checkbox. * * @param array $item Row. */ protected function column_cb( $item ) { return sprintf( '', esc_attr( $item['key'] ), esc_attr( $item['name'] ) ); } /** * Product column with the row actions. * * @param array $item Row. */ protected function column_item( $item ) { $edit = get_edit_post_link( $item['product_id'] ); $name = $edit ? '' . esc_html( $item['name'] ) . '' : '' . esc_html( $item['name'] ) . ''; $meta = array_filter( array( '' !== $item['sku'] ? 'SKU ' . $item['sku'] : '', $item['variation_id'] ? '#' . $item['variation_id'] : '#' . $item['product_id'] ) ); return $name . '
' . esc_html( implode( ' ยท ', $meta ) ) . '' . $this->row_actions( $this->actions_for( $item ) ); } /** * The actions that make sense for a row. * * @param array $item Row. * @return array Link HTML by key. */ private function actions_for( array $item ): array { $channel_label = $this->plugin->channel()->label(); $status = null === $item['listing'] ? StatusLabels::NOT_LISTED : $item['listing']['status']; $on = in_array( $status, ListingStatus::on_marketplace(), true ); $actions = array( 'preview' => '' . esc_html__( 'Preview', 'tidelist' ) . '', ); if ( in_array( $status, array( StatusLabels::NOT_LISTED, ListingStatus::DRAFT, ListingStatus::ERROR, ListingStatus::REMOVED ), true ) && ! $on ) { $actions['select'] = $this->do_link( 'select', $item['key'], __( 'Select', 'tidelist' ) ); } if ( ListingStatus::QUEUED === $status ) { $actions['unselect'] = $this->do_link( 'unselect', $item['key'], __( 'Unselect', 'tidelist' ) ); } if ( null !== $item['listing'] && '' !== (string) $item['listing']['remote_id'] ) { $actions['status'] = $this->do_link( 'status', $item['key'], __( 'Check status', 'tidelist' ) ); } if ( $on ) { $actions['update'] = $this->do_link( 'update', $item['key'], __( 'Update now', 'tidelist' ) ); $actions['deactivate'] = $this->do_link( 'deactivate', $item['key'], __( 'Take down', 'tidelist' ) ); } if ( ListingStatus::INACTIVE === $status && ! empty( $item['can_relist'] ) ) { $actions['relist'] = $this->do_link( 'relist', $item['key'], __( 'List again', 'tidelist' ) ); } if ( null !== $item['listing'] && '' !== (string) $item['listing']['remote_url'] ) { $actions['open'] = '' . esc_html( Text::channel( __( 'Open on {channel}', 'tidelist' ), $channel_label ) ) . ''; } return $actions; } /** * A one-click action link with a nonce. * * @param string $operation Action name. * @param string $key Item key. * @param string $label Label. */ private function do_link( string $operation, string $key, string $label ): string { $url = wp_nonce_url( add_query_arg( array( 'action' => ListingActions::DO_ONE, 'do' => $operation, 'item' => $key, ), admin_url( 'admin-post.php' ) ), ListingActions::DO_ONE ); return '' . esc_html( $label ) . ''; } /** * Category column. * * @param array $item Row. */ protected function column_category( $item ) { if ( ! $item['remote_category'] ) { return '' . esc_html__( 'Not mapped', 'tidelist' ) . ''; } $tree = $this->plugin->channel_for( (string) ( $item['listing']['channel'] ?? '' ) )->taxonomy()->index(); $path = $tree->has( $item['remote_category'] ) ? $tree->path( $item['remote_category'] ) : '#' . $item['remote_category']; $bits = explode( ' > ', $path ); return '' . esc_html( end( $bits ) ) . ''; } /** * A stored error as readable text with the marketplace's words under "Details". * * @param string $stored Stored error text. */ public static function error_html( string $stored ): string { list( $readable, $details ) = ErrorExplainer::split( $stored ); $html = '
' . esc_html( mb_substr( $readable, 0, 400 ) ) . ''; if ( '' !== $details ) { $html .= '
' . esc_html__( 'Details', 'tidelist' ) . '' . esc_html( mb_substr( $details, 0, 1000 ) ) . '
'; } return $html; } /** * Price column. * * @param array $item Row. */ protected function column_price( $item ) { return wp_kses_post( $item['price_html'] ); } /** * Stock column. * * @param array $item Row. */ protected function column_stock( $item ) { return esc_html( $item['stock'] ); } /** * Status column. * * @param array $item Row. */ protected function column_status( $item ) { $status = null === $item['listing'] ? StatusLabels::NOT_LISTED : (string) $item['listing']['status']; $html = StatusLabels::badge( $status ); if ( null !== $item['listing'] && '' !== trim( (string) $item['listing']['last_error'] ) ) { $html .= self::error_html( (string) $item['listing']['last_error'] ); } if ( ListingStatus::INACTIVE === $status && ! empty( $item['can_relist'] ) ) { $html .= '
' . esc_html__( 'Back in stock: could be listed again.', 'tidelist' ) . ''; } return $html; } /** * Last sync column. * * @param array $item Row. */ protected function column_sync( $item ) { $at = null === $item['listing'] ? '' : (string) $item['listing']['last_synced_at']; return '' === $at ? 'โ€”' : esc_html( sprintf( /* translators: %s: time difference */ __( '%s ago', 'tidelist' ), human_time_diff( (int) strtotime( $at . ' UTC' ) ) ) ); } /** * Empty table text. */ public function no_items() { esc_html_e( 'No products here yet.', 'tidelist' ); } /** * Loads the rows of the current page. */ public function prepare_items() { $this->_column_headers = array( $this->get_columns(), array(), array() ); $view = $this->current_view(); $paged = max( 1, (int) $this->get_pagenum() ); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only filters. $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; $cat = isset( $_GET['product_cat'] ) ? sanitize_title( wp_unslash( $_GET['product_cat'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended $items = array(); $total = 0; if ( self::VIEW_ALL === $view || self::VIEW_NOT_LISTED === $view ) { $args = array( 'type' => array( 'simple', 'variation' ), 'status' => array( 'publish', 'private', 'draft' ), // Only what can be advertised: an advert for an item the shop does not have is a paid placement for nothing. // Adverts that already exist stay visible under their status tabs. 'stock_status' => 'instock', 'limit' => self::PER_PAGE, 'page' => $paged, 'paginate' => true, 'orderby' => 'date', 'order' => 'DESC', ); if ( '' !== $search ) { $args['s'] = $search; } if ( '' !== $cat ) { $args['category'] = array( $cat ); } if ( self::VIEW_NOT_LISTED === $view ) { $listed = array(); foreach ( $this->plugin->listings()->all() as $row ) { $listed[] = $row['variation_id'] ? (int) $row['variation_id'] : (int) $row['product_id']; } if ( $listed ) { $args['exclude'] = $listed; // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Bounded by the number of listings; WooCommerce paginates the result. } } $found = wc_get_products( $args ); $total = (int) $found->total; foreach ( $found->products as $product ) { $items[] = $this->item_for_product( $product ); } } else { $counts = $this->plugin->listings()->count_by_status(); $total = (int) ( $counts[ $view ] ?? 0 ); foreach ( $this->plugin->listings()->all( array( 'status' => $view ), self::PER_PAGE, ( $paged - 1 ) * self::PER_PAGE ) as $row ) { $product = wc_get_product( $row['variation_id'] ? (int) $row['variation_id'] : (int) $row['product_id'] ); $items[] = $product ? $this->item_for_product( $product, $row ) : $this->item_for_missing( $row ); } } $this->items = $items; $this->set_pagination_args( array( 'total_items' => $total, 'per_page' => self::PER_PAGE, ) ); } /** * Display data of a WooCommerce item. * * @param \WC_Product $product Product or variation. * @param array|null $row Its listing row, if already loaded. */ private function item_for_product( \WC_Product $product, ?array $row = null ): array { $is_variation = $product instanceof \WC_Product_Variation; $product_id = $is_variation ? (int) $product->get_parent_id() : (int) $product->get_id(); $variation_id = $is_variation ? (int) $product->get_id() : 0; if ( null === $row ) { $row = $this->plugin->listing_service()->find( $product_id, $variation_id ? $variation_id : null ); } $name = $is_variation ? wp_strip_all_tags( $product->get_name() ) : $product->get_name(); $stock = $product->managing_stock() ? (string) (int) $product->get_stock_quantity() : ( 'instock' === $product->get_stock_status() ? __( 'In stock', 'tidelist' ) : __( 'Out of stock', 'tidelist' ) ); $facts = $row && ListingStatus::INACTIVE === $row['status'] ? ( new \Tidelist\Woo\ItemFacts() )->read( $row ) : array(); return array( 'key' => $product_id . ':' . $variation_id, 'product_id' => $product_id, 'variation_id' => $variation_id, 'name' => $name, 'sku' => (string) $product->get_sku(), 'price_html' => $product->get_price_html(), 'stock' => $stock, 'remote_category' => $this->plugin->remote_category_for( $product_id, '', $row && '' !== (string) $row['remote_id'] ? (int) ( $row['remote_category'] ?? 0 ) : 0 ), 'listing' => $row, 'can_relist' => $facts && \Tidelist\Sync\TakedownRules::can_relist( $facts ), ); } /** * Display data of a listing whose product no longer exists. * * @param array $row Listing row. */ private function item_for_missing( array $row ): array { return array( 'key' => (int) $row['product_id'] . ':' . (int) $row['variation_id'], 'product_id' => (int) $row['product_id'], 'variation_id' => (int) $row['variation_id'], 'name' => sprintf( /* translators: %d: product ID */ __( '(deleted product #%d)', 'tidelist' ), (int) $row['product_id'] ), 'sku' => '', 'price_html' => 'โ€”', 'stock' => 'โ€”', 'remote_category' => 0, 'listing' => $row, 'can_relist' => false, ); } }