register_hooks(); } /** * Running instance (null if WooCommerce is missing). */ public static function instance(): ?self { return self::$instance; } /** * Constructor: builds shared services. */ private function __construct() { $this->box = KeyFactory::box(); $this->settings = new Settings( $this->box, BuiltIn::settings_migrations() ); $this->logger = new DbLogger(); } /** * Registers WordPress hooks. */ private function register_hooks(): void { $this->settings->persist_migration(); add_action( 'init', array( $this, 'load_textdomain' ) ); ( new SiteHealth( $this ) )->register(); ( new Privacy( $this ) )->register(); // Background job: registered on every request, because Action Scheduler runs outside wp-admin. $this->job_monitor()->register(); add_action( DailyMaintenance::HOOK, array( $this, 'run_daily_maintenance' ) ); add_action( PublishBatch::HOOK, array( new PublishBatch( $this ), 'run' ), 10, 2 ); add_action( Takedown::HOOK, array( new Takedown( $this ), 'run' ) ); add_action( Takedown::SWEEP, array( new Takedown( $this ), 'sweep' ) ); add_action( LoadCategories::HOOK, array( new LoadCategories( $this ), 'run' ) ); add_action( UpdateListing::HOOK, array( new UpdateListing( $this ), 'run' ) ); add_action( Relist::HOOK, array( new Relist( $this ), 'run' ) ); add_action( StatusPoll::HOOK, array( new StatusPoll( $this ), 'run' ) ); // Stock changes also happen outside wp-admin (imports, REST, cron), so the watcher runs on every request. ( new ProductWatcher( $this ) )->register(); if ( is_admin() ) { add_action( 'admin_init', array( DailyMaintenance::class, 'ensure_scheduled' ) ); add_action( 'admin_init', array( Takedown::class, 'ensure_scheduled' ) ); add_action( 'admin_init', array( StatusPoll::class, 'ensure_scheduled' ) ); ( new Scripts() )->register(); ( new Menu( $this ) )->register(); ( new Actions( $this ) )->register(); ( new ListingActions( $this ) )->register(); ( new OnboardingPage( $this ) )->register(); ( new MappingActions( $this ) )->register(); ( new Notices( $this ) )->register(); } } /** * Loads translations (Bulgarian, Polish, Romanian, Portuguese, Ukrainian and Russian ship with the plugin). */ public function load_textdomain(): void { load_plugin_textdomain( self::TEXT_DOMAIN, false, dirname( plugin_basename( TIDELIST_FILE ) ) . '/languages' ); // phpcs:ignore PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound -- Ships its own translations for languages that are not on translate.wordpress.org yet. } /** * Action Scheduler callback. */ public function run_daily_maintenance(): void { ( new DailyMaintenance( $this->channels(), $this->logger ) )->run(); } /** * Watches the background jobs. */ public function job_monitor(): JobMonitor { if ( null === $this->monitor ) { $this->monitor = new JobMonitor(); } return $this->monitor; } /** * Settings. */ public function settings(): Settings { return $this->settings; } /** * Logger. */ public function logger(): Logger { return $this->logger; } /** * Whether the Pro add-on is active. */ public static function is_pro(): bool { return (bool) apply_filters( 'tidelist_is_pro_active', false ); } /** * How many marketplace accounts may be connected (1 in the free plugin; the Pro add-on raises it). */ public static function max_accounts(): int { return max( 1, (int) apply_filters( 'tidelist_max_accounts', 1 ) ); } /** * The marketplaces: the built-in ones, plus any another plugin adds with the action "tidelist_register_channels". */ public function channels(): ChannelRegistry { if ( null === $this->channels ) { $this->channels = new ChannelRegistry(); BuiltIn::register( $this->channels, $this->settings, $this->box, $this->logger, TIDELIST_DIR . 'data/', Actions::site_callback_uri() ); /** * Lets another plugin register a marketplace channel. * * @param ChannelRegistry $registry Registry. */ do_action( 'tidelist_register_channels', $this->channels ); } return $this->channels; } /** * Marketplace channel by ID (default: the default channel). * * @param string|null $id Channel ID. */ public function channel( ?string $id = null ): ChannelInterface { return $this->channels()->get( null === $id || '' === $id ? $this->default_channel_id() : $id ); } /** * The channel with this ID, or the default channel when there is none (for example a listing of a channel whose * plugin is no longer active), so screens still draw. * * @param string $id Channel ID. */ public function channel_for( string $id ): ChannelInterface { return $this->channels()->has( $id ) ? $this->channels()->get( $id ) : $this->channel(); } /** * The channel a listing belongs to (the default channel when the row or its channel is gone). * * @param int $listing_id Listing ID. */ public function channel_of( int $listing_id ): ChannelInterface { $row = $this->listings()->find( $listing_id ); return $this->channel_for( null === $row ? '' : (string) $row['channel'] ); } /** * The channel a request names in its "channel" field (a button, form or link), else the default channel. */ public function requested_channel(): ChannelInterface { $id = isset( $_REQUEST['channel'] ) ? sanitize_key( wp_unslash( $_REQUEST['channel'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- selects what to show; actions check their nonce first. return $this->channels()->has( $id ) ? $this->channels()->get( $id ) : $this->channel(); } /** * The channel screens and actions use when none is named: the first registered one, unless a filter picks another. */ public function default_channel_id(): string { $ids = array_keys( $this->channels()->all() ); $id = (string) apply_filters( 'tidelist_default_channel', (string) reset( $ids ) ); return $this->channels()->has( $id ) ? $id : (string) reset( $ids ); } /** * Categories and attributes of a channel. * * @param string|null $channel_id Channel ID (default channel when null). */ public function taxonomy( ?string $channel_id = null ): Taxonomy { return $this->channel( $channel_id )->taxonomy(); } /** * Attribute rules of a channel. * * @param string|null $channel_id Channel ID (default channel when null). */ public function attribute_map( ?string $channel_id = null ): AttributeMap { return AttributeMap::from_setting( (array) $this->settings->get( 'attribute_map' ), $this->channel( $channel_id )->id() ); } /** * WooCommerce to marketplace category map of a channel. * * @param string|null $channel_id Channel ID (default channel when null). */ public function category_map( ?string $channel_id = null ): CategoryMap { return CategoryMap::from_setting( (array) $this->settings->get( 'category_map' ), $this->channel( $channel_id )->id() ); } /** * Listings (one row per product or variation on a marketplace account). */ public function listings(): ListingRepository { return new ListingRepository(); } /** * What the owner does with listings (select, unselect, link). * * @param string|null $channel_id Channel ID (default channel when null). */ public function listing_service( ?string $channel_id = null ): ListingService { return new ListingService( $this->listings(), $this->channel( $channel_id )->id(), 0 ); } /** * Builds the advert for a listing row (no marketplace call). Null if the product no longer exists. * * @param array $row Listing row. */ public function build_advert( array $row ): ?BuildResult { $variation = (int) ( $row['variation_id'] ?? 0 ); $current = '' !== (string) ( $row['remote_id'] ?? '' ) ? (int) ( $row['remote_category'] ?? 0 ) : 0; return $this->build_listing( (int) $row['product_id'], $variation ? $variation : null, null, (string) ( $row['channel'] ?? '' ), $current ); } /** * Photo paths the owner chose not to send for a product. * * @param int $product_id Product ID (the parent for a variation). * @return string[] */ public function excluded_photos_of( int $product_id ): array { $exclusions = (array) $this->settings->get( 'photo_exclusions' ); return array_values( array_map( 'strval', (array) ( $exclusions[ $product_id ] ?? array() ) ) ); } /** * The marketplace category of a product: the one chosen for this product, else the one its advert is already in * at the marketplace (so a change made there by hand is kept), else the closest mapped shop category. * 0 when none exists. * * @param int $product_id Product ID (the parent for a variation). * @param string $channel_id Channel ID ('' = the default channel). * @param int $current Category of the product's advert at the marketplace (0 = no advert or unknown). */ public function remote_category_for( int $product_id, string $channel_id = '', int $current = 0 ): int { $channel = $this->channel( $channel_id ); $chosen = $this->category_override_of( $product_id, $channel->id() ); if ( $chosen > 0 ) { return $chosen; } if ( $current > 0 ) { return $current; } return (int) ( new CategoryResolver() )->remote_category( $product_id, $this->category_map( $channel->id() ) ); } /** * The category a product's advert is in at the marketplace, as last read (0 = no advert or unknown). * * @param int $product_id Product ID (the parent for a variation). * @param int|null $variation_id Variation ID. * @param string $channel_id Channel ID ('' = the default channel). */ public function current_category_of( int $product_id, ?int $variation_id = null, string $channel_id = '' ): int { $row = $this->listings()->find_for_item( $product_id, $variation_id, $this->channel( $channel_id )->id(), 0 ); return $row && '' !== (string) $row['remote_id'] ? (int) ( $row['remote_category'] ?? 0 ) : 0; } /** * Other marketplace categories the product's shop categories lead to, besides the one used. Empty when the * category was chosen for this product or is already fixed by its advert, because then nothing is guessed. * * @param int $product_id Product ID (the parent for a variation). * @param string $channel_id Channel ID ('' = the default channel). * @param int $current Category of the product's advert at the marketplace (0 = none). * @return int[] */ public function category_alternatives( int $product_id, string $channel_id = '', int $current = 0 ): array { $channel = $this->channel( $channel_id ); if ( $current > 0 || $this->category_override_of( $product_id, $channel->id() ) > 0 ) { return array(); } $all = ( new CategoryResolver() )->remote_candidates( $product_id, $this->category_map( $channel->id() ) ); return array_slice( $all, 1 ); } /** * The marketplace category chosen for one product, ahead of the category mapping (0 = none). * * @param int $product_id Product ID (the parent for a variation). * @param string $channel_id Channel ID ('' = the default channel). */ public function category_override_of( int $product_id, string $channel_id = '' ): int { return ( new CategoryOverrides( (array) $this->settings->get( 'category_overrides' ) ) )->get( $this->channel( $channel_id )->id(), $product_id ); } /** * Chooses the marketplace category of one product (0 = back to the category mapping). * * @param int $product_id Product ID (the parent for a variation). * @param int $category Marketplace category ID, or 0. * @param string $channel_id Channel ID ('' = the default channel). */ public function set_category_override( int $product_id, int $category, string $channel_id = '' ): void { $overrides = new CategoryOverrides( (array) $this->settings->get( 'category_overrides' ) ); $this->settings->update( array( 'category_overrides' => $overrides->with( $this->channel( $channel_id )->id(), $product_id, $category ) ) ); } /** * Builds the advert for a product or variation (no marketplace call). Null if it no longer exists. * * @param int $product_id Product ID (the parent for a variation). * @param int|null $variation_id Variation ID, or null for a simple product. * @param string[]|null $excluded Photo paths not to send (null = the ones the shop owner chose for this product). * @param string $channel_id Channel ID ('' = the default channel). * @param int $current Category of the item's advert at the marketplace, when it has one (-1 = look it up). */ public function build_listing( int $product_id, ?int $variation_id = null, ?array $excluded = null, string $channel_id = '', int $current = -1 ): ?BuildResult { $item = ( new ProductSnapshot() )->read( $product_id, $variation_id ); if ( null === $item ) { return null; } $channel = $this->channel( $channel_id ); $item['images'] = array_values( array_diff( $item['all_images'], null === $excluded ? $this->excluded_photos_of( $product_id ) : $excluded ) ); if ( $current < 0 ) { $current = $this->current_category_of( $product_id, $variation_id, $channel->id() ); } $item['remote_category'] = $this->remote_category_for( $product_id, $channel->id(), $current ); $item['category_alternatives'] = $this->category_alternatives( $product_id, $channel->id(), $current ); $product = wc_get_product( $product_id ); $variation = $variation_id ? wc_get_product( $variation_id ) : null; $reader = new ProductValues(); $values = static function ( AttributeRule $rule ) use ( $reader, $product, $variation ): array { return $reader->values( $product, $variation instanceof \WC_Product ? $variation : null, $rule ); }; /** * Text for the {measurements} placeholder (the Pro add-on adds size charts here). * * @param string $text Text. * @param int $product Product ID. * @param int $variation Variation ID (0 for a simple product). */ $item['measurements'] = (string) apply_filters( 'tidelist_measurements', '', $product_id, (int) $variation_id ); $price_filter = static function ( float $price, array $source, int $category ): float { /** * Final say on the advert price (the Pro add-on's price rules). * * @param float $price Price in the marketplace currency. * @param array $item Product data. * @param int $category Marketplace category ID. */ return (float) apply_filters( 'tidelist_price', $price, $source, $category ); }; /** * Settings the builder uses for this item (the Pro add-on swaps the title and description templates per category). * * @param array $settings Plugin settings. * @param array $item Product data. */ $core_settings = $this->settings->all(); unset( $core_settings['channels'] ); // Marketplace settings (and their secrets) stay with the channel. $build_settings = (array) apply_filters( 'tidelist_build_settings', $core_settings, $item ); $builder = new ListingBuilder( $channel, $build_settings, $this->attribute_map( $channel->id() ), $price_filter ); $result = $builder->build( $item, $values ); if ( null !== $result->listing ) { /** * Last chance to change the advert before it is sent or compared with what was sent. * * @param \Tidelist\Channel\Listing $listing Listing. * @param array $item Product data. */ $filtered = apply_filters( 'tidelist_listing', $result->listing, $item ); if ( $filtered instanceof \Tidelist\Channel\Listing ) { $result->listing = $filtered; } } $result->info['all_photos'] = implode( "\n", $item['all_images'] ); $this->prepare_photo_copies( $result, $channel->label() ); return $result; } /** * Makes the JPG copies for photos a marketplace cannot take as they are. A copy that cannot be made, or that * the marketplace could not download from the photo address, becomes an error, so it can never be sent broken. * * @param BuildResult $result Build result. * @param string $label Marketplace name. */ private function prepare_photo_copies( BuildResult $result, string $label ): void { $paths = array_filter( explode( "\n", (string) ( $result->info['converted'] ?? '' ) ) ); if ( ! $paths ) { return; } $image_base = (string) $this->settings->get( 'image_base_url' ); foreach ( ( new PhotoConverter( $image_base ) )->ensure( $paths ) as $path => $error ) { if ( '' !== $error ) { $result->error( 'photo_convert_failed', sprintf( 'The JPG copy of %1$s could not be made: %2$s.', $path, $error ) ); } } if ( ! PhotoPaths::copies_are_public( $image_base, home_url() ) ) { $result->error( 'photo_copy_not_public', Text::channel( 'The JPG copies of .webp photos can only be downloaded by {channel} once the plugin runs on the site that serves the photos.', $label ) ); } } /** * Shown when WooCommerce is missing or too old. */ public static function woocommerce_missing_notice(): void { if ( ! current_user_can( 'activate_plugins' ) ) { return; } printf( '

%s

', esc_html( sprintf( /* translators: %s: minimum WooCommerce version */ __( 'Tidelist needs WooCommerce %s or newer to be active. The plugin is not running.', 'tidelist' ), TIDELIST_MIN_WC ) ) ); } }