diff --git a/data-machine-events.php b/data-machine-events.php index db175183..a1737ad5 100644 --- a/data-machine-events.php +++ b/data-machine-events.php @@ -62,10 +62,6 @@ function data_machine_events_sanitize_query_params( $value ) { require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Core/event-dates-sync.php'; require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Core/EventDatesTable.php'; -// Artist URL Submissions table (moderation queue for the #320 import flow). -require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Core/ArtistUrlSubmissionsTable.php'; -add_action( 'plugins_loaded', array( \DataMachineEvents\Core\ArtistUrlSubmissionsTable::class, 'maybe_install' ), 20 ); - // Global alias — event-dates-sync.php is namespaced so this makes the public API accessible globally. if ( ! function_exists( 'datamachine_get_event_dates' ) ) { /** @@ -194,10 +190,6 @@ public function init() { new \DataMachineEvents\Admin\Settings_Page(); } - // Artist URL Submissions moderation queue (extrachill-events#320). - if ( class_exists( 'DataMachineEvents\\Admin\\ArtistUrlSubmissionsAdmin' ) ) { - new \DataMachineEvents\Admin\ArtistUrlSubmissionsAdmin(); - } } add_action( 'init', array( $this, 'init_data_machine_integration' ), 25 ); @@ -448,11 +440,6 @@ function ( array $templates ): array { new \DataMachineEvents\Abilities\MergedBillDecideAbilities(); } - if ( file_exists( DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/ArtistUrlImportAbilities.php' ) ) { - require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/ArtistUrlImportAbilities.php'; - new \DataMachineEvents\Abilities\ArtistUrlImportAbilities(); - } - // Chat tools for the merged-bill agent decision step (issue #256). if ( class_exists( 'DataMachineEvents\\Api\\Chat\\Tools\\MergedBillInspect' ) ) { new \DataMachineEvents\Api\Chat\Tools\MergedBillInspect(); @@ -575,7 +562,6 @@ public function enqueue_admin_assets( $hook ) { public function activate() { \DataMachineEvents\Core\EventDatesTable::create_table(); - \DataMachineEvents\Core\ArtistUrlSubmissionsTable::create_table(); $this->register_post_types(); $this->register_taxonomies(); flush_rewrite_rules(); diff --git a/inc/Abilities/ArtistUrlImportAbilities.php b/inc/Abilities/ArtistUrlImportAbilities.php deleted file mode 100644 index a90fa707..00000000 --- a/inc/Abilities/ArtistUrlImportAbilities.php +++ /dev/null @@ -1,1147 +0,0 @@ -registerAbilities(); - self::$registered = true; - } - } - - /** - * Register all four abilities. Each registration is gated on - * `wp_abilities_api_init` so registration is idempotent regardless - * of when this class is instantiated. - */ - private function registerAbilities(): void { - $register_callback = function () { - $this->registerPreviewAbility(); - $this->registerSubmitAbility(); - $this->registerApproveAbility(); - $this->registerRejectAbility(); - }; - - if ( did_action( 'wp_abilities_api_init' ) ) { - $register_callback(); - } else { - add_action( 'wp_abilities_api_init', $register_callback ); - } - } - - // ──────────────────────────────────────────────────────────────────── - // Ability registration - // ──────────────────────────────────────────────────────────────────── - - private function registerPreviewAbility(): void { - wp_register_ability( - 'data-machine-events/preview-artist-url', - array( - 'label' => __( 'Preview Artist Tour URL', 'data-machine-events' ), - 'description' => __( 'Probe a tour/events URL via UniversalWebScraper. Returns detected format, event count, preview events, and a suggested artist binding. Non-destructive — no submission row is created.', 'data-machine-events' ), - 'category' => 'datamachine-events-events', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'url' ), - 'properties' => array( - 'url' => array( - 'type' => 'string', - 'format' => 'uri', - 'description' => __( 'Tour/events page URL to probe.', 'data-machine-events' ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'detected_format' => array( 'type' => 'string' ), - 'events_found' => array( 'type' => 'integer' ), - 'events_preview' => array( 'type' => 'array' ), - 'suggested_artist_name' => array( 'type' => 'string' ), - 'suggested_artist_term_id' => array( 'type' => array( 'integer', 'null' ) ), - 'source_metadata' => array( 'type' => 'object' ), - ), - ), - 'execute_callback' => array( $this, 'executePreview' ), - 'permission_callback' => array( $this, 'permissionLoggedIn' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - } - - private function registerSubmitAbility(): void { - wp_register_ability( - 'data-machine-events/submit-artist-url', - array( - 'label' => __( 'Submit Artist Tour URL', 'data-machine-events' ), - 'description' => __( 'Submit a tour/events URL for admin review. Re-probes the URL server-side and inserts a moderation-queue row.', 'data-machine-events' ), - 'category' => 'datamachine-events-events', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'url' ), - 'properties' => array( - 'url' => array( 'type' => 'string', 'format' => 'uri' ), - 'contact_email' => array( 'type' => 'string' ), - 'contact_name' => array( 'type' => 'string' ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'submission_id' => array( 'type' => 'integer' ), - 'status' => array( 'type' => 'string' ), - 'message' => array( 'type' => 'string' ), - 'events_found' => array( 'type' => 'integer' ), - ), - ), - 'execute_callback' => array( $this, 'executeSubmit' ), - 'permission_callback' => array( $this, 'permissionLoggedIn' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - } - - private function registerApproveAbility(): void { - wp_register_ability( - 'data-machine-events/approve-artist-url-submission', - array( - 'label' => __( 'Approve Artist URL Submission', 'data-machine-events' ), - 'description' => __( 'Approve a pending submission: resolves the artist term, creates a pipeline+flow with universal_web_scraper, runs the first scrape immediately.', 'data-machine-events' ), - 'category' => 'datamachine-events-events', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'submission_id' ), - 'properties' => array( - 'submission_id' => array( 'type' => 'integer' ), - 'artist_term_id' => array( 'type' => 'integer' ), - 'artist_name' => array( 'type' => 'string' ), - 'schedule_interval' => array( 'type' => 'string' ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'pipeline_id' => array( 'type' => 'integer' ), - 'flow_id' => array( 'type' => 'integer' ), - 'artist_term_id' => array( 'type' => 'integer' ), - 'events_imported_immediately' => array( 'type' => array( 'integer', 'null' ) ), - ), - ), - 'execute_callback' => array( $this, 'executeApprove' ), - 'permission_callback' => array( $this, 'permissionAdmin' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - } - - private function registerRejectAbility(): void { - wp_register_ability( - 'data-machine-events/reject-artist-url-submission', - array( - 'label' => __( 'Reject Artist URL Submission', 'data-machine-events' ), - 'description' => __( 'Mark a pending submission as rejected with an optional reason.', 'data-machine-events' ), - 'category' => 'datamachine-events-events', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'submission_id' ), - 'properties' => array( - 'submission_id' => array( 'type' => 'integer' ), - 'reason' => array( 'type' => 'string' ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - ), - ), - 'execute_callback' => array( $this, 'executeReject' ), - 'permission_callback' => array( $this, 'permissionAdmin' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - } - - // ──────────────────────────────────────────────────────────────────── - // Permission callbacks - // ──────────────────────────────────────────────────────────────────── - - public function permissionLoggedIn(): bool { - if ( defined( 'WP_CLI' ) && WP_CLI ) { - return true; - } - return is_user_logged_in(); - } - - public function permissionAdmin(): bool { - if ( defined( 'WP_CLI' ) && WP_CLI ) { - return true; - } - return current_user_can( 'manage_options' ); - } - - // ──────────────────────────────────────────────────────────────────── - // preview-artist-url - // ──────────────────────────────────────────────────────────────────── - - public function executePreview( array $input ): array|\WP_Error { - $raw_url = isset( $input['url'] ) ? (string) $input['url'] : ''; - $url = esc_url_raw( $raw_url ); - - if ( '' === $url ) { - return new \WP_Error( 'invalid_url', __( 'URL is required.', 'data-machine-events' ), array( 'status' => 400 ) ); - } - - // Protocol whitelist — http/https only. esc_url_raw() already enforces - // this against the default allowed protocols, but be explicit. - $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); - if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) { - return new \WP_Error( 'invalid_protocol', __( 'Only http and https URLs are supported.', 'data-machine-events' ), array( 'status' => 400 ) ); - } - - $normalized = ArtistUrlSubmissionsTable::normalize_url( $url ); - if ( '' === $normalized ) { - return new \WP_Error( 'invalid_url', __( 'URL could not be parsed.', 'data-machine-events' ), array( 'status' => 400 ) ); - } - - $hash = ArtistUrlSubmissionsTable::url_hash( $normalized ); - $existing = ArtistUrlSubmissionsTable::find_by_hash( $hash ); - if ( $existing && in_array( $existing['status'], array( - ArtistUrlSubmissionsTable::STATUS_PENDING_REVIEW, - ArtistUrlSubmissionsTable::STATUS_APPROVED, - ), true ) ) { - return new \WP_Error( - 'url_already_tracked', - __( 'This URL is already being tracked.', 'data-machine-events' ), - array( - 'status' => 409, - 'existing_status' => $existing['status'], - 'submission_id' => (int) $existing['id'], - ) - ); - } - - $probe = $this->probeUrl( $normalized ); - if ( is_wp_error( $probe ) ) { - return $probe; - } - - if ( 0 === $probe['events_found'] ) { - return new \WP_Error( - 'no_events_found', - __( "We couldn't extract events from that page. Try the manual form below.", 'data-machine-events' ), - array( 'status' => 422 ) - ); - } - - $suggestion = $this->suggestArtist( $normalized, $probe ); - - return array( - 'success' => true, - 'detected_format' => (string) $probe['detected_format'], - 'events_found' => (int) $probe['events_found'], - 'events_preview' => $probe['events_preview'], - 'suggested_artist_name' => (string) $suggestion['name'], - 'suggested_artist_term_id' => $suggestion['term_id'], - 'source_metadata' => $probe['source_metadata'], - ); - } - - // ──────────────────────────────────────────────────────────────────── - // submit-artist-url - // ──────────────────────────────────────────────────────────────────── - - public function executeSubmit( array $input ): array|\WP_Error { - $raw_url = isset( $input['url'] ) ? (string) $input['url'] : ''; - $url = esc_url_raw( $raw_url ); - if ( '' === $url ) { - return new \WP_Error( 'invalid_url', __( 'URL is required.', 'data-machine-events' ), array( 'status' => 400 ) ); - } - - $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); - if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) { - return new \WP_Error( 'invalid_protocol', __( 'Only http and https URLs are supported.', 'data-machine-events' ), array( 'status' => 400 ) ); - } - - $normalized = ArtistUrlSubmissionsTable::normalize_url( $url ); - if ( '' === $normalized ) { - return new \WP_Error( 'invalid_url', __( 'URL could not be parsed.', 'data-machine-events' ), array( 'status' => 400 ) ); - } - - $hash = ArtistUrlSubmissionsTable::url_hash( $normalized ); - $existing = ArtistUrlSubmissionsTable::find_by_hash( $hash ); - if ( $existing && in_array( $existing['status'], array( - ArtistUrlSubmissionsTable::STATUS_PENDING_REVIEW, - ArtistUrlSubmissionsTable::STATUS_APPROVED, - ), true ) ) { - return new \WP_Error( - 'url_already_tracked', - __( 'This URL is already being tracked.', 'data-machine-events' ), - array( - 'status' => 409, - 'existing_status' => $existing['status'], - 'submission_id' => (int) $existing['id'], - ) - ); - } - - // Resolve submitter identity. Logged-in users override any - // client-provided name/email with the WP user record. - $user_id = get_current_user_id(); - $contact_email = isset( $input['contact_email'] ) ? sanitize_email( (string) $input['contact_email'] ) : ''; - $contact_name = isset( $input['contact_name'] ) ? sanitize_text_field( (string) $input['contact_name'] ) : ''; - - if ( $user_id > 0 ) { - $user = wp_get_current_user(); - $contact_email = (string) $user->user_email; - $contact_name = (string) ( $user->display_name ?: $user->user_login ); - } else { - // Issue #320 says "any logged-in user" — we hard-reject - // anonymous to match that contract. - return new \WP_Error( - 'login_required', - __( 'You must be logged in to submit a tour URL.', 'data-machine-events' ), - array( 'status' => 401 ) - ); - } - - // Re-probe server-side regardless of what the preview saw. - $probe = $this->probeUrl( $normalized ); - - if ( is_wp_error( $probe ) || 0 === ( $probe['events_found'] ?? 0 ) ) { - $submission_id = ArtistUrlSubmissionsTable::insert( - array( - 'user_id' => $user_id, - 'contact_email' => $contact_email, - 'contact_name' => $contact_name, - 'url' => $normalized, - 'url_hash' => $hash, - 'detected_format' => '', - 'events_found_count' => 0, - 'status' => ArtistUrlSubmissionsTable::STATUS_SCRAPING_FAILED, - ) - ); - - return array( - 'success' => false, - 'submission_id' => (int) $submission_id, - 'status' => ArtistUrlSubmissionsTable::STATUS_SCRAPING_FAILED, - 'message' => __( "We couldn't extract events from that page. Try the manual form below.", 'data-machine-events' ), - 'events_found' => 0, - ); - } - - $suggestion = $this->suggestArtist( $normalized, $probe ); - - $submission_id = ArtistUrlSubmissionsTable::insert( - array( - 'user_id' => $user_id, - 'contact_email' => $contact_email, - 'contact_name' => $contact_name, - 'url' => $normalized, - 'url_hash' => $hash, - 'suggested_artist_name' => $suggestion['name'], - 'suggested_artist_term_id' => $suggestion['term_id'], - 'detected_format' => $probe['detected_format'], - 'events_found_count' => (int) $probe['events_found'], - 'status' => ArtistUrlSubmissionsTable::STATUS_PENDING_REVIEW, - ) - ); - - if ( null === $submission_id ) { - return new \WP_Error( 'insert_failed', __( 'Failed to record submission.', 'data-machine-events' ), array( 'status' => 500 ) ); - } - - return array( - 'success' => true, - 'submission_id' => (int) $submission_id, - 'status' => ArtistUrlSubmissionsTable::STATUS_PENDING_REVIEW, - 'message' => __( "Submitted for review. We'll set up automatic imports if approved.", 'data-machine-events' ), - 'events_found' => (int) $probe['events_found'], - ); - } - - // ──────────────────────────────────────────────────────────────────── - // approve-artist-url-submission - // ──────────────────────────────────────────────────────────────────── - - public function executeApprove( array $input ): array|\WP_Error { - $submission_id = (int) ( $input['submission_id'] ?? 0 ); - if ( $submission_id <= 0 ) { - return new \WP_Error( 'invalid_submission_id', __( 'submission_id is required.', 'data-machine-events' ), array( 'status' => 400 ) ); - } - - $submission = ArtistUrlSubmissionsTable::get( $submission_id ); - if ( ! $submission ) { - return new \WP_Error( 'not_found', __( 'Submission not found.', 'data-machine-events' ), array( 'status' => 404 ) ); - } - - if ( ArtistUrlSubmissionsTable::STATUS_PENDING_REVIEW !== $submission['status'] ) { - return new \WP_Error( - 'invalid_state', - sprintf( - /* translators: %s: current submission status */ - __( 'Submission is in %s state; only pending_review submissions can be approved.', 'data-machine-events' ), - $submission['status'] - ), - array( 'status' => 409 ) - ); - } - - // Resolve artist term. - $artist_term_id = $this->resolveArtistTerm( - isset( $input['artist_term_id'] ) ? (int) $input['artist_term_id'] : 0, - isset( $input['artist_name'] ) ? (string) $input['artist_name'] : '', - isset( $submission['suggested_artist_term_id'] ) ? (int) $submission['suggested_artist_term_id'] : 0 - ); - - if ( is_wp_error( $artist_term_id ) ) { - return $artist_term_id; - } - - $interval = isset( $input['schedule_interval'] ) ? sanitize_key( (string) $input['schedule_interval'] ) : self::DEFAULT_INTERVAL; - if ( ! in_array( $interval, self::ALLOWED_INTERVALS, true ) ) { - $interval = self::DEFAULT_INTERVAL; - } - - $artist_term = get_term( $artist_term_id, 'artist' ); - $artist_name = ( $artist_term && ! is_wp_error( $artist_term ) ) ? (string) $artist_term->name : 'Artist ' . $artist_term_id; - - $pipeline_name = sprintf( '%s — Tour Import', $artist_name ); - - // 1. Create the pipeline scaffold (event_import → ai → update). - $pipeline_ability = wp_get_ability( 'datamachine/create-pipeline' ); - if ( ! $pipeline_ability ) { - return new \WP_Error( 'missing_ability', __( 'datamachine/create-pipeline ability is not available.', 'data-machine-events' ), array( 'status' => 500 ) ); - } - - $pipeline_result = $pipeline_ability->execute( - array( - 'pipeline_name' => $pipeline_name, - 'steps' => array( - array( 'step_type' => 'event_import', 'label' => 'Event Import' ), - array( 'step_type' => 'ai', 'label' => 'AI Agent' ), - array( 'step_type' => 'update', 'label' => 'Update' ), - ), - ) - ); - - if ( empty( $pipeline_result['success'] ) || empty( $pipeline_result['pipeline_id'] ) ) { - $err = $pipeline_result['error'] ?? 'Unknown error'; - return new \WP_Error( 'pipeline_creation_failed', 'Failed to create pipeline: ' . $err, array( 'status' => 500 ) ); - } - - $pipeline_id = (int) $pipeline_result['pipeline_id']; - - // 2. Set the AI step's system prompt to focus on this artist. - $this->configurePipelineAiStep( $pipeline_id, $artist_name ); - - // 3. Create the flow with universal_web_scraper handler and - // the SelectionMode-driven taxonomy bindings. - $flow_ability = wp_get_ability( 'datamachine/create-flow' ); - if ( ! $flow_ability ) { - return new \WP_Error( 'missing_ability', __( 'datamachine/create-flow ability is not available.', 'data-machine-events' ), array( 'status' => 500 ) ); - } - - $update_handler_config = array( - 'post_status' => 'publish', - 'include_images' => false, - 'post_author' => self::DEFAULT_POST_AUTHOR, - 'taxonomy_artist_selection' => (string) $artist_term_id, - 'taxonomy_venue_selection' => SelectionMode::AI_DECIDES, - 'taxonomy_location_selection' => SelectionMode::AI_DECIDES, - 'taxonomy_festival_selection' => SelectionMode::AI_DECIDES, - 'taxonomy_promoter_selection' => SelectionMode::SKIP, - 'taxonomy_category_selection' => SelectionMode::SKIP, - 'taxonomy_post_tag_selection' => SelectionMode::SKIP, - ); - - $import_handler_config = array( - 'source_url' => $submission['url'], - 'search' => '', - 'exclude_keywords' => '', - ); - - $ai_message = sprintf( - /* translators: %s: artist name */ - __( 'Process this event from %s\'s tour page. The artist is already known and pre-selected. Identify the venue, city, and festival (if any) at extraction time.', 'data-machine-events' ), - $artist_name - ); - - $flow_result = $flow_ability->execute( - array( - 'pipeline_id' => $pipeline_id, - 'flow_name' => $artist_name . ' — Tour URL', - 'scheduling_config' => array( 'interval' => $interval ), - 'step_configs' => array( - 'event_import' => array( - 'handler_slug' => 'universal_web_scraper', - 'handler_config' => $import_handler_config, - ), - 'update' => array( - 'handler_slug' => 'upsert_event', - 'handler_config' => $update_handler_config, - ), - 'ai' => array( - 'user_message' => $ai_message, - ), - ), - ) - ); - - if ( empty( $flow_result['success'] ) || empty( $flow_result['flow_id'] ) ) { - $err = $flow_result['error'] ?? 'Unknown error'; - return new \WP_Error( 'flow_creation_failed', 'Failed to create flow: ' . $err, array( 'status' => 500 ) ); - } - - $flow_id = (int) $flow_result['flow_id']; - - // Defensive patch in case create-flow's step_configs application - // doesn't write through (matches CityAbilities' patchFlowSteps - // belt-and-braces pattern). - $this->patchFlowSteps( - $flow_id, - $import_handler_config, - $update_handler_config, - $ai_message - ); - - // 4. Update the submission row: approved + linked pipeline/flow. - ArtistUrlSubmissionsTable::update( - $submission_id, - array( - 'status' => ArtistUrlSubmissionsTable::STATUS_APPROVED, - 'pipeline_id' => $pipeline_id, - 'flow_id' => $flow_id, - 'artist_term_id' => $artist_term_id, - 'reviewed_at' => current_time( 'mysql', true ), - 'reviewed_by' => get_current_user_id(), - ) - ); - - // 5. Trigger first scrape immediately. - $events_imported_immediately = null; - $run_ability = wp_get_ability( 'datamachine/run-flow' ); - if ( $run_ability ) { - $run_result = $run_ability->execute( array( 'flow_id' => $flow_id ) ); - $events_imported_immediately = ! empty( $run_result['success'] ); - } - - return array( - 'success' => true, - 'pipeline_id' => $pipeline_id, - 'flow_id' => $flow_id, - 'artist_term_id' => $artist_term_id, - 'events_imported_immediately' => $events_imported_immediately, - ); - } - - // ──────────────────────────────────────────────────────────────────── - // reject-artist-url-submission - // ──────────────────────────────────────────────────────────────────── - - public function executeReject( array $input ): array|\WP_Error { - $submission_id = (int) ( $input['submission_id'] ?? 0 ); - if ( $submission_id <= 0 ) { - return new \WP_Error( 'invalid_submission_id', __( 'submission_id is required.', 'data-machine-events' ), array( 'status' => 400 ) ); - } - - $submission = ArtistUrlSubmissionsTable::get( $submission_id ); - if ( ! $submission ) { - return new \WP_Error( 'not_found', __( 'Submission not found.', 'data-machine-events' ), array( 'status' => 404 ) ); - } - - $reason = isset( $input['reason'] ) ? sanitize_textarea_field( (string) $input['reason'] ) : ''; - - ArtistUrlSubmissionsTable::update( - $submission_id, - array( - 'status' => ArtistUrlSubmissionsTable::STATUS_REJECTED, - 'rejection_reason' => $reason, - 'reviewed_at' => current_time( 'mysql', true ), - 'reviewed_by' => get_current_user_id(), - ) - ); - - return array( 'success' => true ); - } - - // ──────────────────────────────────────────────────────────────────── - // Shared helpers - // ──────────────────────────────────────────────────────────────────── - - /** - * Probe a URL through UniversalWebScraper and normalize the result. - * - * Returns an array with: - * detected_format (string) - * events_found (int) - * events_preview (array of up to 5 event records) - * source_metadata (array) - * raw_first_event (array) ← used by suggestArtist() - * page_html (string) ← used by suggestArtist() - * - * On infrastructure error returns a WP_Error. - * - * @param string $url Already-normalized URL. - * @return array|\WP_Error - */ - private function probeUrl( string $url ) { - $config = array( - 'source_url' => $url, - 'flow_step_id' => 'preview_' . wp_generate_uuid4(), - 'flow_id' => 'preview', - 'search' => '', - ); - - $handler = new UniversalWebScraper(); - $results = $handler->get_fetch_data( 'preview', $config, null ); - - if ( empty( $results ) ) { - return array( - 'detected_format' => '', - 'events_found' => 0, - 'events_preview' => array(), - 'source_metadata' => array(), - 'raw_first_event' => array(), - 'page_html' => '', - ); - } - - $packet_entries = array(); - foreach ( $results as $packet_obj ) { - $packet_array = $packet_obj->addTo( array() ); - $packet_entry = $packet_array[0] ?? array(); - if ( ! empty( $packet_entry ) ) { - $packet_entries[] = $packet_entry; - } - } - - $detected_format = ''; - $first_meta = $packet_entries[0]['metadata'] ?? array(); - if ( is_array( $first_meta ) ) { - $detected_format = (string) ( $first_meta['extraction_method'] ?? $first_meta['source_type'] ?? '' ); - } - - $events_preview = array(); - $raw_first_event = array(); - $count = 0; - foreach ( $packet_entries as $entry ) { - $body = (string) ( $entry['data']['body'] ?? '' ); - $decoded = '' !== $body ? json_decode( $body, true ) : null; - if ( ! is_array( $decoded ) ) { - continue; - } - $event = $decoded['event'] ?? null; - if ( ! is_array( $event ) ) { - continue; - } - ++$count; - if ( empty( $raw_first_event ) ) { - $raw_first_event = $event; - } - if ( count( $events_preview ) < 5 ) { - $events_preview[] = array( - 'title' => (string) ( $event['title'] ?? '' ), - 'startDate' => (string) ( $event['startDate'] ?? '' ), - 'startTime' => (string) ( $event['startTime'] ?? '' ), - 'venue' => (string) ( $event['venue'] ?? '' ), - 'ticketUrl' => (string) ( $event['ticketUrl'] ?? '' ), - ); - } - } - - return array( - 'detected_format' => $detected_format, - 'events_found' => $count, - 'events_preview' => $events_preview, - 'source_metadata' => $first_meta, - 'raw_first_event' => $raw_first_event, - 'page_html' => $this->fetchPageHtml( $url ), - ); - } - - /** - * Fetch the page HTML for metadata-based artist name detection. - * Best-effort, short-timeout, no caching — failure is non-fatal. - * - * @param string $url - * @return string Raw HTML body, or '' on error. - */ - private function fetchPageHtml( string $url ): string { - $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); - if ( '' === $host ) { - $host = 'localhost'; - } - - $default_user_agent = sprintf( - 'Mozilla/5.0 (compatible; DataMachineEventsBot/1.0; +https://%s)', - $host - ); - - /** - * Filter the User-Agent sent when fetching an artist's tour/events page. - * - * @param string $default_user_agent UA derived from the deploying site host. - * @param string $url The page URL being fetched. - */ - $user_agent = (string) apply_filters( 'dm_events_fetch_user_agent', $default_user_agent, $url ); - - $response = wp_safe_remote_get( - $url, - array( - 'timeout' => 5, - 'redirection' => 3, - 'user-agent' => $user_agent, - ) - ); - - if ( is_wp_error( $response ) ) { - return ''; - } - - $code = wp_remote_retrieve_response_code( $response ); - if ( $code < 200 || $code >= 300 ) { - return ''; - } - - return (string) wp_remote_retrieve_body( $response ); - } - - /** - * Suggest an artist binding for a probed URL. - * - * Tries (in order): - * 1. JSON-LD `Performer` / `MusicGroup` on the first extracted event. - * 2. og:title / / first <h1> on the fetched HTML. - * 3. URL domain → Title Case. - * - * Then fuzzy-matches the result against existing `artist` terms - * using similar_text(). Returns: - * { name: string, term_id: int|null } - * - * @param string $url Normalized URL. - * @param array $probe Output from probeUrl(). - * @return array{name:string,term_id:int|null} - */ - private function suggestArtist( string $url, array $probe ): array { - $candidates = array(); - - // 1. JSON-LD / structured data on the first event. - $first = $probe['raw_first_event'] ?? array(); - if ( is_array( $first ) ) { - $performer = $first['performer'] ?? $first['artist'] ?? ''; - if ( is_array( $performer ) ) { - $performer = $performer['name'] ?? ''; - } - if ( is_string( $performer ) && '' !== trim( $performer ) ) { - $candidates[] = trim( $performer ); - } - } - - $html = $probe['page_html'] ?? ''; - if ( '' !== $html ) { - // 2a. og:title - if ( preg_match( '/<meta[^>]+property=[\"\']og:title[\"\'][^>]+content=[\"\']([^\"\']+)[\"\']/i', $html, $m ) ) { - $candidates[] = $this->stripSiteTokens( html_entity_decode( $m[1] ) ); - } - // 2b. <title> - if ( preg_match( '/<title>([^<]+)<\/title>/i', $html, $m ) ) { - $candidates[] = $this->stripSiteTokens( html_entity_decode( $m[1] ) ); - } - // 2c. first <h1> - if ( preg_match( '/<h1[^>]*>(.*?)<\/h1>/is', $html, $m ) ) { - $candidates[] = $this->stripSiteTokens( wp_strip_all_tags( $m[1] ) ); - } - } - - // 3. URL domain. - $host = (string) wp_parse_url( $url, PHP_URL_HOST ); - if ( '' !== $host ) { - $host = preg_replace( '/^www\./i', '', $host ); - $root = explode( '.', $host )[0]; - if ( '' !== $root ) { - $candidates[] = $this->titleCaseFromSlug( $root ); - } - } - - $name = ''; - foreach ( $candidates as $candidate ) { - $candidate = trim( (string) $candidate ); - if ( '' !== $candidate ) { - $name = $candidate; - break; - } - } - - $term_id = $this->fuzzyMatchArtistTerm( $name ); - - return array( - 'name' => $name, - 'term_id' => $term_id, - ); - } - - /** - * Strip site-name / generic suffixes from a page title or h1, e.g. - * "Theo Katzman | Tour" → "Theo Katzman", "Tour - Theo Katzman" → "Theo Katzman". - * - * @param string $title - * @return string - */ - private function stripSiteTokens( string $title ): string { - $title = trim( $title ); - // Split on common separators and drop any segment that's a generic token. - $generic = array( 'tour', 'tours', 'events', 'shows', 'concerts', 'live', 'calendar', 'gigs', 'tour dates' ); - $parts = preg_split( '/\s*[|\-–—:]\s*/u', $title ); - if ( ! is_array( $parts ) || empty( $parts ) ) { - return $title; - } - - $kept = array(); - foreach ( $parts as $part ) { - $normalized = strtolower( trim( $part ) ); - if ( in_array( $normalized, $generic, true ) ) { - continue; - } - $kept[] = trim( $part ); - } - - if ( empty( $kept ) ) { - return $title; - } - - // The longest remaining segment is usually the artist name. - usort( - $kept, - static function ( $a, $b ) { - return mb_strlen( $b ) <=> mb_strlen( $a ); - } - ); - - return $kept[0]; - } - - /** - * Convert a URL slug to Title Case ("theokatzman" → "Theokatzman", - * "theo-katzman" → "Theo Katzman"). - * - * @param string $slug - * @return string - */ - private function titleCaseFromSlug( string $slug ): string { - $slug = preg_replace( '/[\-_]+/', ' ', $slug ); - return ucwords( trim( (string) $slug ) ); - } - - /** - * Fuzzy-match a candidate artist name against existing `artist` - * taxonomy terms using similar_text() percentage. - * - * Returns the closest term ID if its similarity meets - * ARTIST_FUZZY_MATCH_THRESHOLD, else null. - * - * @param string $name - * @return int|null - */ - private function fuzzyMatchArtistTerm( string $name ): ?int { - $name = trim( $name ); - if ( '' === $name || ! taxonomy_exists( 'artist' ) ) { - return null; - } - - // Try an exact match first (cheap path, common case). - $exact = get_term_by( 'name', $name, 'artist' ); - if ( $exact && ! is_wp_error( $exact ) ) { - return (int) $exact->term_id; - } - - // Fall back to slug match — handles minor casing/punctuation drift. - $by_slug = get_term_by( 'slug', sanitize_title( $name ), 'artist' ); - if ( $by_slug && ! is_wp_error( $by_slug ) ) { - return (int) $by_slug->term_id; - } - - // Fuzzy scan: walk artist terms and keep the highest similar_text - // percentage. Capped to a reasonable batch so this stays O(N) on - // small-to-medium artist taxonomies (current site is ~1200 terms). - $terms = get_terms( - array( - 'taxonomy' => 'artist', - 'hide_empty' => false, - 'number' => 5000, - 'fields' => 'id=>name', - ) - ); - - if ( is_wp_error( $terms ) || empty( $terms ) ) { - return null; - } - - $best_id = null; - $best_pct = 0.0; - foreach ( $terms as $term_id => $term_name ) { - $pct = 0.0; - similar_text( strtolower( $name ), strtolower( (string) $term_name ), $pct ); - if ( $pct > $best_pct ) { - $best_pct = $pct; - $best_id = (int) $term_id; - } - } - - return ( $best_pct >= self::ARTIST_FUZZY_MATCH_THRESHOLD ) ? $best_id : null; - } - - /** - * Resolve the artist term ID during approval. - * - * Resolution order: - * 1. Explicit `artist_term_id` input. - * 2. Explicit `artist_name` input (looks up existing, creates if missing). - * 3. Submission's suggested term_id. - * - * Returns the term ID, or a WP_Error if none of the above yields a - * valid term. - * - * @param int $explicit_id - * @param string $explicit_name - * @param int $suggested_id - * @return int|\WP_Error - */ - private function resolveArtistTerm( int $explicit_id, string $explicit_name, int $suggested_id ) { - if ( ! taxonomy_exists( 'artist' ) ) { - return new \WP_Error( 'artist_taxonomy_missing', __( 'Artist taxonomy is not registered on this site.', 'data-machine-events' ), array( 'status' => 500 ) ); - } - - if ( $explicit_id > 0 ) { - $term = get_term( $explicit_id, 'artist' ); - if ( $term && ! is_wp_error( $term ) ) { - return (int) $term->term_id; - } - } - - $explicit_name = trim( $explicit_name ); - if ( '' !== $explicit_name ) { - $existing = get_term_by( 'name', $explicit_name, 'artist' ); - if ( $existing && ! is_wp_error( $existing ) ) { - return (int) $existing->term_id; - } - - $inserted = wp_insert_term( $explicit_name, 'artist' ); - if ( is_wp_error( $inserted ) ) { - // Slug collision — try slug lookup as a recovery. - $by_slug = get_term_by( 'slug', sanitize_title( $explicit_name ), 'artist' ); - if ( $by_slug && ! is_wp_error( $by_slug ) ) { - return (int) $by_slug->term_id; - } - return new \WP_Error( 'artist_create_failed', $inserted->get_error_message(), array( 'status' => 500 ) ); - } - return (int) $inserted['term_id']; - } - - if ( $suggested_id > 0 ) { - $term = get_term( $suggested_id, 'artist' ); - if ( $term && ! is_wp_error( $term ) ) { - return (int) $term->term_id; - } - } - - return new \WP_Error( - 'artist_required', - __( 'Provide artist_term_id or artist_name, or have a suggested_artist_term_id on the submission.', 'data-machine-events' ), - array( 'status' => 400 ) - ); - } - - /** - * Configure the pipeline AI step with an artist-scoped system - * prompt. Does not set a provider/model — those are resolved by - * AIStep from agent_config and site settings at runtime. - * - * @param int $pipeline_id - * @param string $artist_name - */ - private function configurePipelineAiStep( int $pipeline_id, string $artist_name ): void { - global $wpdb; - $table = $wpdb->prefix . 'datamachine_pipelines'; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $pipeline = $wpdb->get_row( - $wpdb->prepare( "SELECT pipeline_config FROM {$table} WHERE pipeline_id = %d", $pipeline_id ), - ARRAY_A - ); - - if ( ! $pipeline ) { - return; - } - - $config = json_decode( $pipeline['pipeline_config'], true ); - if ( ! is_array( $config ) ) { - return; - } - - foreach ( $config as &$step ) { - if ( ( $step['step_type'] ?? '' ) === 'ai' ) { - // Do NOT write a provider/model into the pipeline AI step. - // AIStep resolves the model/provider from agent_config and - // site settings at runtime; baking a literal here silently - // overrides the operator's configured model on every - // approved artist pipeline. - /** - * Filter the events feed name written into the AI step prompt. - * - * Defaults to the deploying site's name; a generic distributable - * plugin must not bake one site's brand into runtime AI behavior. - * - * @param string $feed_name Default feed name (site name). - */ - $feed_name = (string) apply_filters( 'dm_events_feed_name', get_bloginfo( 'name' ) ); - - $step['system_prompt'] = sprintf( - 'You process events from %1$s\'s tour/events page for the %2$s events feed. The artist is %1$s and is already pre-selected — do not change the artist binding. Identify the venue, city/location, and festival (if any) for each event based on the available information. Skip WordPress categories and post tags entirely.', - $artist_name, - $feed_name - ); - $step['enabled_tools'] = array(); - } - } - unset( $step ); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->update( - $table, - array( 'pipeline_config' => wp_json_encode( $config ) ), - array( 'pipeline_id' => $pipeline_id ) - ); - } - - /** - * Belt-and-braces flow-step patch — writes handler slugs/configs and - * AI user_message directly to the flow_config JSON. Mirrors - * CityAbilities::patchFlowSteps() to harden against the same - * create-flow timing quirks that affected city pipelines. - * - * @param int $flow_id - * @param array $import_handler_config - * @param array $update_handler_config - * @param string $ai_message - */ - private function patchFlowSteps( int $flow_id, array $import_handler_config, array $update_handler_config, string $ai_message ): void { - global $wpdb; - $table = $wpdb->prefix . 'datamachine_flows'; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $flow = $wpdb->get_row( - $wpdb->prepare( "SELECT flow_config FROM {$table} WHERE flow_id = %d", $flow_id ), - ARRAY_A - ); - if ( ! $flow ) { - return; - } - - $config = json_decode( $flow['flow_config'], true ); - if ( ! is_array( $config ) ) { - return; - } - - foreach ( $config as &$step ) { - $step_type = $step['step_type'] ?? ''; - - if ( 'event_import' === $step_type ) { - $step['handler_slugs'] = array( 'universal_web_scraper' ); - $step['handler_configs'] = array( 'universal_web_scraper' => $import_handler_config ); - $step['enabled'] = true; - } - - if ( 'update' === $step_type ) { - $step['handler_slugs'] = array( 'upsert_event' ); - $step['handler_configs'] = array( 'upsert_event' => $update_handler_config ); - $step['enabled'] = true; - } - - if ( 'ai' === $step_type ) { - $step['user_message'] = $ai_message; - } - } - unset( $step ); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->update( - $table, - array( 'flow_config' => wp_json_encode( $config ) ), - array( 'flow_id' => $flow_id ) - ); - } -} diff --git a/inc/Admin/ArtistUrlSubmissionsAdmin.php b/inc/Admin/ArtistUrlSubmissionsAdmin.php deleted file mode 100644 index 820f2b28..00000000 --- a/inc/Admin/ArtistUrlSubmissionsAdmin.php +++ /dev/null @@ -1,324 +0,0 @@ -<?php -/** - * Artist URL Submissions Admin - * - * Adds a submenu under the Events post-type menu for moderating - * URL-based artist tour import submissions queued by the - * `data-machine-events/submit-artist-url` ability. - * - * The screen is intentionally simple (v1): - * - Status filter tabs: Pending review (default), Approved, Rejected, Failed scrapes. - * - One row per submission with submitter, URL, suggested artist, events found. - * - Approve form: artist term ID OR new artist name + schedule interval. - * - Reject form: optional reason. - * - * Form submits POST back to `admin-post.php` with a nonce — same shape - * Settings_Page uses for its writes. Each form delegates to the - * corresponding ability via wp_get_ability(), then redirects back to - * the screen with a status flash. - * - * @package DataMachineEvents\Admin - * @since 0.40.0 - */ - -namespace DataMachineEvents\Admin; - -use DataMachineEvents\Core\ArtistUrlSubmissionsTable; -use DataMachineEvents\Core\Event_Post_Type; - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -class ArtistUrlSubmissionsAdmin { - - const PAGE_SLUG = 'data-machine-events-artist-url-submissions'; - - const NONCE_APPROVE = 'datamachine_events_artist_url_approve'; - const NONCE_REJECT = 'datamachine_events_artist_url_reject'; - - public function __construct() { - add_filter( 'data_machine_events_post_type_menu_items', array( $this, 'add_menu_item' ) ); - - // admin-post.php endpoints for the inline forms. - add_action( 'admin_post_' . self::NONCE_APPROVE, array( $this, 'handle_approve_post' ) ); - add_action( 'admin_post_' . self::NONCE_REJECT, array( $this, 'handle_reject_post' ) ); - } - - public function add_menu_item( $allowed_items ) { - $allowed_items['artist_url_submissions'] = array( - 'type' => 'submenu', - 'callback' => array( $this, 'register_submenu' ), - ); - return $allowed_items; - } - - public function register_submenu(): void { - add_submenu_page( - 'edit.php?post_type=' . Event_Post_Type::POST_TYPE, - __( 'Artist URL Submissions', 'data-machine-events' ), - __( 'Artist URL Imports', 'data-machine-events' ), - 'manage_options', - self::PAGE_SLUG, - array( $this, 'render_page' ) - ); - } - - public function render_page(): void { - if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'data-machine-events' ) ); - } - - $status = isset( $_GET['status'] ) ? sanitize_key( wp_unslash( (string) $_GET['status'] ) ) : ArtistUrlSubmissionsTable::STATUS_PENDING_REVIEW; - $allowed_statuses = array( - ArtistUrlSubmissionsTable::STATUS_PENDING_REVIEW, - ArtistUrlSubmissionsTable::STATUS_APPROVED, - ArtistUrlSubmissionsTable::STATUS_REJECTED, - ArtistUrlSubmissionsTable::STATUS_SCRAPING_FAILED, - ); - if ( ! in_array( $status, $allowed_statuses, true ) ) { - $status = ArtistUrlSubmissionsTable::STATUS_PENDING_REVIEW; - } - - $counts = ArtistUrlSubmissionsTable::counts_by_status(); - $submissions = ArtistUrlSubmissionsTable::list_by_status( $status, 100, 0 ); - - $base_url = admin_url( 'edit.php?post_type=' . Event_Post_Type::POST_TYPE . '&page=' . self::PAGE_SLUG ); - - $flash = isset( $_GET['flash'] ) ? sanitize_key( wp_unslash( (string) $_GET['flash'] ) ) : ''; - - echo '<div class="wrap">'; - echo '<h1>' . esc_html__( 'Artist URL Imports', 'data-machine-events' ) . '</h1>'; - echo '<p>' . esc_html__( 'Moderation queue for URL-based artist tour imports submitted via the event-submission form. Approving a row creates a Data Machine pipeline scoped to the chosen artist; rejecting closes it without creating one.', 'data-machine-events' ) . '</p>'; - - if ( 'approved' === $flash ) { - echo '<div class="notice notice-success"><p>' . esc_html__( 'Submission approved and pipeline created.', 'data-machine-events' ) . '</p></div>'; - } elseif ( 'rejected' === $flash ) { - echo '<div class="notice notice-success"><p>' . esc_html__( 'Submission rejected.', 'data-machine-events' ) . '</p></div>'; - } elseif ( 'error' === $flash ) { - $msg = isset( $_GET['msg'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['msg'] ) ) : __( 'Action failed.', 'data-machine-events' ); - echo '<div class="notice notice-error"><p>' . esc_html( $msg ) . '</p></div>'; - } - - // Status tabs. - echo '<ul class="subsubsub">'; - $tabs = array( - ArtistUrlSubmissionsTable::STATUS_PENDING_REVIEW => __( 'Pending review', 'data-machine-events' ), - ArtistUrlSubmissionsTable::STATUS_APPROVED => __( 'Approved', 'data-machine-events' ), - ArtistUrlSubmissionsTable::STATUS_REJECTED => __( 'Rejected', 'data-machine-events' ), - ArtistUrlSubmissionsTable::STATUS_SCRAPING_FAILED => __( 'Failed scrapes', 'data-machine-events' ), - ); - $last_idx = count( $tabs ) - 1; - $i = 0; - foreach ( $tabs as $tab_key => $tab_label ) { - $url = add_query_arg( 'status', $tab_key, $base_url ); - $count = (int) ( $counts[ $tab_key ] ?? 0 ); - $is_active = ( $tab_key === $status ); - echo '<li><a href="' . esc_url( $url ) . '"' . ( $is_active ? ' class="current"' : '' ) . '>'; - echo esc_html( $tab_label ) . ' <span class="count">(' . esc_html( (string) $count ) . ')</span>'; - echo '</a>' . ( $i < $last_idx ? ' |' : '' ) . '</li>'; - ++$i; - } - echo '</ul>'; - echo '<br class="clear" />'; - - // Table. - if ( empty( $submissions ) ) { - echo '<p>' . esc_html__( 'No submissions in this status.', 'data-machine-events' ) . '</p>'; - echo '</div>'; - return; - } - - echo '<table class="wp-list-table widefat striped">'; - echo '<thead><tr>'; - echo '<th>' . esc_html__( 'ID', 'data-machine-events' ) . '</th>'; - echo '<th>' . esc_html__( 'URL', 'data-machine-events' ) . '</th>'; - echo '<th>' . esc_html__( 'Submitter', 'data-machine-events' ) . '</th>'; - echo '<th>' . esc_html__( 'Suggested artist', 'data-machine-events' ) . '</th>'; - echo '<th>' . esc_html__( 'Events found', 'data-machine-events' ) . '</th>'; - echo '<th>' . esc_html__( 'Detected format', 'data-machine-events' ) . '</th>'; - echo '<th>' . esc_html__( 'Submitted', 'data-machine-events' ) . '</th>'; - echo '<th>' . esc_html__( 'Actions', 'data-machine-events' ) . '</th>'; - echo '</tr></thead><tbody>'; - - foreach ( $submissions as $row ) { - $this->render_row( $row, $status ); - } - - echo '</tbody></table>'; - echo '</div>'; - } - - private function render_row( array $row, string $status_context ): void { - $submitter = (string) ( $row['contact_name'] ?: $row['contact_email'] ?: '—' ); - $user_id = (int) ( $row['user_id'] ?? 0 ); - if ( $user_id > 0 ) { - $user = get_userdata( $user_id ); - if ( $user ) { - $submitter = sprintf( '%s (#%d)', $user->display_name ?: $user->user_login, $user_id ); - } - } - - $suggested = (string) ( $row['suggested_artist_name'] ?? '' ); - if ( ! empty( $row['suggested_artist_term_id'] ) ) { - $term = get_term( (int) $row['suggested_artist_term_id'], 'artist' ); - if ( $term && ! is_wp_error( $term ) ) { - $suggested .= sprintf( ' (term #%d: %s)', $term->term_id, $term->name ); - } - } - - echo '<tr>'; - echo '<td>' . esc_html( (string) $row['id'] ) . '</td>'; - echo '<td><a href="' . esc_url( $row['url'] ) . '" target="_blank" rel="noopener">' . esc_html( $row['url'] ) . '</a></td>'; - echo '<td>' . esc_html( $submitter ) . '</td>'; - echo '<td>' . esc_html( $suggested ?: '—' ) . '</td>'; - echo '<td>' . esc_html( (string) (int) $row['events_found_count'] ) . '</td>'; - echo '<td><code>' . esc_html( (string) ( $row['detected_format'] ?? '' ) ) . '</code></td>'; - echo '<td>' . esc_html( (string) $row['created_at'] ) . '</td>'; - echo '<td>'; - - if ( ArtistUrlSubmissionsTable::STATUS_PENDING_REVIEW === $status_context ) { - $this->render_approve_form( (int) $row['id'], $row ); - echo '<hr style="margin: 8px 0; opacity: 0.4;" />'; - $this->render_reject_form( (int) $row['id'] ); - } elseif ( ArtistUrlSubmissionsTable::STATUS_APPROVED === $status_context ) { - if ( ! empty( $row['pipeline_id'] ) ) { - echo '<a href="' . esc_url( admin_url( 'admin.php?page=data-machine&pipeline_id=' . (int) $row['pipeline_id'] ) ) . '">'; - echo esc_html__( 'View pipeline', 'data-machine-events' ); - echo '</a>'; - } else { - echo '—'; - } - } elseif ( ArtistUrlSubmissionsTable::STATUS_REJECTED === $status_context ) { - $reason = (string) ( $row['rejection_reason'] ?? '' ); - echo esc_html( $reason ?: __( 'Rejected (no reason)', 'data-machine-events' ) ); - } else { - // scraping_failed — read-only. - echo '<em>' . esc_html__( 'No actions — investigate handler coverage.', 'data-machine-events' ) . '</em>'; - } - - echo '</td>'; - echo '</tr>'; - } - - private function render_approve_form( int $submission_id, array $row ): void { - $action_url = admin_url( 'admin-post.php' ); - $suggested_term_id = (int) ( $row['suggested_artist_term_id'] ?? 0 ); - $suggested_name = (string) ( $row['suggested_artist_name'] ?? '' ); - ?> - <form method="post" action="<?php echo esc_url( $action_url ); ?>" style="display: block; margin-bottom: 6px;"> - <?php wp_nonce_field( self::NONCE_APPROVE . '_' . $submission_id ); ?> - <input type="hidden" name="action" value="<?php echo esc_attr( self::NONCE_APPROVE ); ?>" /> - <input type="hidden" name="submission_id" value="<?php echo esc_attr( (string) $submission_id ); ?>" /> - - <label style="display: block; margin: 2px 0;"> - <?php esc_html_e( 'Existing artist term ID:', 'data-machine-events' ); ?> - <input type="number" name="artist_term_id" min="0" value="<?php echo esc_attr( $suggested_term_id ?: '' ); ?>" style="width: 100px;" /> - </label> - <label style="display: block; margin: 2px 0;"> - <?php esc_html_e( 'OR new artist name:', 'data-machine-events' ); ?> - <input type="text" name="artist_name" value="<?php echo esc_attr( $suggested_term_id ? '' : $suggested_name ); ?>" style="width: 200px;" /> - </label> - <label style="display: block; margin: 2px 0;"> - <?php esc_html_e( 'Schedule:', 'data-machine-events' ); ?> - <select name="schedule_interval"> - <option value="weekly" selected><?php esc_html_e( 'Weekly', 'data-machine-events' ); ?></option> - <option value="daily"><?php esc_html_e( 'Daily', 'data-machine-events' ); ?></option> - <option value="monthly"><?php esc_html_e( 'Monthly', 'data-machine-events' ); ?></option> - </select> - </label> - <button type="submit" class="button button-primary"><?php esc_html_e( 'Approve', 'data-machine-events' ); ?></button> - </form> - <?php - } - - private function render_reject_form( int $submission_id ): void { - $action_url = admin_url( 'admin-post.php' ); - ?> - <form method="post" action="<?php echo esc_url( $action_url ); ?>" style="display: block;"> - <?php wp_nonce_field( self::NONCE_REJECT . '_' . $submission_id ); ?> - <input type="hidden" name="action" value="<?php echo esc_attr( self::NONCE_REJECT ); ?>" /> - <input type="hidden" name="submission_id" value="<?php echo esc_attr( (string) $submission_id ); ?>" /> - <label style="display: block; margin: 2px 0;"> - <?php esc_html_e( 'Rejection reason (optional):', 'data-machine-events' ); ?> - <input type="text" name="reason" style="width: 220px;" /> - </label> - <button type="submit" class="button"><?php esc_html_e( 'Reject', 'data-machine-events' ); ?></button> - </form> - <?php - } - - // ──────────────────────────────────────────────────────────────────── - // admin-post.php handlers - // ──────────────────────────────────────────────────────────────────── - - public function handle_approve_post(): void { - if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'Forbidden.', 'data-machine-events' ), 403 ); - } - - $submission_id = isset( $_POST['submission_id'] ) ? (int) $_POST['submission_id'] : 0; - check_admin_referer( self::NONCE_APPROVE . '_' . $submission_id ); - - $ability = wp_get_ability( 'data-machine-events/approve-artist-url-submission' ); - if ( ! $ability ) { - $this->redirect_with_flash( 'pending_review', 'error', __( 'Ability not registered.', 'data-machine-events' ) ); - } - - $result = $ability->execute( - array( - 'submission_id' => $submission_id, - 'artist_term_id' => isset( $_POST['artist_term_id'] ) ? (int) $_POST['artist_term_id'] : 0, - 'artist_name' => isset( $_POST['artist_name'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['artist_name'] ) ) : '', - 'schedule_interval' => isset( $_POST['schedule_interval'] ) ? sanitize_key( wp_unslash( (string) $_POST['schedule_interval'] ) ) : 'weekly', - ) - ); - - if ( is_wp_error( $result ) ) { - $this->redirect_with_flash( 'pending_review', 'error', $result->get_error_message() ); - } - - $this->redirect_with_flash( 'approved', 'approved' ); - } - - public function handle_reject_post(): void { - if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'Forbidden.', 'data-machine-events' ), 403 ); - } - - $submission_id = isset( $_POST['submission_id'] ) ? (int) $_POST['submission_id'] : 0; - check_admin_referer( self::NONCE_REJECT . '_' . $submission_id ); - - $ability = wp_get_ability( 'data-machine-events/reject-artist-url-submission' ); - if ( ! $ability ) { - $this->redirect_with_flash( 'pending_review', 'error', __( 'Ability not registered.', 'data-machine-events' ) ); - } - - $result = $ability->execute( - array( - 'submission_id' => $submission_id, - 'reason' => isset( $_POST['reason'] ) ? sanitize_textarea_field( wp_unslash( (string) $_POST['reason'] ) ) : '', - ) - ); - - if ( is_wp_error( $result ) ) { - $this->redirect_with_flash( 'pending_review', 'error', $result->get_error_message() ); - } - - $this->redirect_with_flash( 'rejected', 'rejected' ); - } - - private function redirect_with_flash( string $status_filter, string $flash, string $msg = '' ): void { - $url = admin_url( 'edit.php?post_type=' . Event_Post_Type::POST_TYPE . '&page=' . self::PAGE_SLUG ); - $url = add_query_arg( - array( - 'status' => $status_filter, - 'flash' => $flash, - 'msg' => $msg ? rawurlencode( $msg ) : '', - ), - $url - ); - wp_safe_redirect( $url ); - exit; - } -} diff --git a/inc/Api/Controllers/ArtistUrlImport.php b/inc/Api/Controllers/ArtistUrlImport.php deleted file mode 100644 index c510c9e8..00000000 --- a/inc/Api/Controllers/ArtistUrlImport.php +++ /dev/null @@ -1,165 +0,0 @@ -<?php -/** - * Artist URL Import REST Controller - * - * Thin wrappers around the four ArtistUrlImportAbilities. Each route is - * just `wp_get_ability( ... )->execute( $input )` — business logic - * lives in the ability, not here. - * - * Direct-navigation guard: preview + submit endpoints inspect the - * Accept / X-Requested-With headers and refuse browser address-bar - * requests, returning a 404 so a curious user pasting the URL doesn't - * see raw JSON. (Same defensive shape #297 calls for on the calendar - * REST endpoints — the prompt names this "BrowserNavigationGuard"; we - * implement it inline since no shared class exists in the tree yet.) - * - * @package DataMachineEvents\Api\Controllers - * @since 0.40.0 - */ - -namespace DataMachineEvents\Api\Controllers; - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -class ArtistUrlImport { - - /** - * Returns true when the request looks like an XHR/JSON client (the - * site's own JS), false when it looks like a direct browser nav. - * - * Accept header containing `application/json` OR an - * `X-Requested-With: XMLHttpRequest` header is enough to pass. - * - * @param \WP_REST_Request $request - * @return bool - */ - public static function looks_like_xhr( \WP_REST_Request $request ): bool { - $xrw = $request->get_header( 'x_requested_with' ); - if ( '' !== (string) $xrw && stripos( (string) $xrw, 'xmlhttprequest' ) !== false ) { - return true; - } - - $accept = (string) $request->get_header( 'accept' ); - if ( '' !== $accept && stripos( $accept, 'application/json' ) !== false ) { - return true; - } - - // POST with a JSON content-type is also fine. - $ct = (string) $request->get_header( 'content_type' ); - if ( '' !== $ct && stripos( $ct, 'application/json' ) !== false ) { - return true; - } - - return false; - } - - /** - * Build a 404-flavored WP_Error for direct-browser navigations. - * - * @return \WP_Error - */ - private static function direct_nav_404(): \WP_Error { - return new \WP_Error( - 'rest_no_route', - __( 'No route was found matching the URL and request method.', 'data-machine-events' ), - array( 'status' => 404 ) - ); - } - - /** - * Wrap an ability invocation in a REST response. Forwards WP_Errors, - * including their `status` field from the ability layer. - * - * @param string $ability_name - * @param array $input - * @return \WP_REST_Response|\WP_Error - */ - private static function run_ability( string $ability_name, array $input ) { - $ability = wp_get_ability( $ability_name ); - if ( ! $ability ) { - return new \WP_Error( - 'ability_missing', - sprintf( - /* translators: %s: ability name */ - __( 'Ability %s is not registered.', 'data-machine-events' ), - $ability_name - ), - array( 'status' => 500 ) - ); - } - - $result = $ability->execute( $input ); - - if ( is_wp_error( $result ) ) { - return $result; - } - - return new \WP_REST_Response( $result, 200 ); - } - - // ──────────────────────────────────────────────────────────────────── - // Route callbacks - // ──────────────────────────────────────────────────────────────────── - - public function preview( \WP_REST_Request $request ) { - if ( ! self::looks_like_xhr( $request ) ) { - return self::direct_nav_404(); - } - - return self::run_ability( - 'data-machine-events/preview-artist-url', - array( 'url' => (string) $request->get_param( 'url' ) ) - ); - } - - public function submit( \WP_REST_Request $request ) { - if ( ! self::looks_like_xhr( $request ) ) { - return self::direct_nav_404(); - } - - return self::run_ability( - 'data-machine-events/submit-artist-url', - array( - 'url' => (string) $request->get_param( 'url' ), - 'contact_email' => (string) $request->get_param( 'contact_email' ), - 'contact_name' => (string) $request->get_param( 'contact_name' ), - ) - ); - } - - public function approve( \WP_REST_Request $request ) { - return self::run_ability( - 'data-machine-events/approve-artist-url-submission', - array( - 'submission_id' => (int) $request->get_param( 'id' ), - 'artist_term_id' => (int) $request->get_param( 'artist_term_id' ), - 'artist_name' => (string) $request->get_param( 'artist_name' ), - 'schedule_interval' => (string) $request->get_param( 'schedule_interval' ), - ) - ); - } - - public function reject( \WP_REST_Request $request ) { - return self::run_ability( - 'data-machine-events/reject-artist-url-submission', - array( - 'submission_id' => (int) $request->get_param( 'id' ), - 'reason' => (string) $request->get_param( 'reason' ), - ) - ); - } - - // ──────────────────────────────────────────────────────────────────── - // Permission callbacks - // ──────────────────────────────────────────────────────────────────── - - public static function permission_logged_in(): bool { - return is_user_logged_in(); - } - - public static function permission_admin(): bool { - return current_user_can( 'manage_options' ); - } -} diff --git a/inc/Api/Routes.php b/inc/Api/Routes.php index d49f50c0..4d8900d6 100644 --- a/inc/Api/Routes.php +++ b/inc/Api/Routes.php @@ -11,7 +11,6 @@ use DataMachineEvents\Api\Controllers\Filters; use DataMachineEvents\Api\Controllers\Geocoding; use DataMachineEvents\Api\Controllers\VenueMap; -use DataMachineEvents\Api\Controllers\ArtistUrlImport; /** * Register REST API routes for Data Machine Events @@ -312,100 +311,6 @@ function register_routes() { ) ); - // Artist URL Import — extrachill-events#320. - $artist_url_import = new ArtistUrlImport(); - - register_rest_route( - API_NAMESPACE, - '/artist-url/preview', - array( - 'methods' => 'POST', - 'callback' => array( $artist_url_import, 'preview' ), - 'permission_callback' => array( ArtistUrlImport::class, 'permission_logged_in' ), - 'args' => array( - 'url' => array( - 'required' => true, - 'type' => 'string', - 'sanitize_callback' => 'esc_url_raw', - ), - ), - ) - ); - - register_rest_route( - API_NAMESPACE, - '/artist-url/submit', - array( - 'methods' => 'POST', - 'callback' => array( $artist_url_import, 'submit' ), - 'permission_callback' => array( ArtistUrlImport::class, 'permission_logged_in' ), - 'args' => array( - 'url' => array( - 'required' => true, - 'type' => 'string', - 'sanitize_callback' => 'esc_url_raw', - ), - 'contact_email' => array( - 'type' => 'string', - 'sanitize_callback' => 'sanitize_email', - ), - 'contact_name' => array( - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - ), - ), - ) - ); - - register_rest_route( - API_NAMESPACE, - '/artist-url/(?P<id>\d+)/approve', - array( - 'methods' => 'POST', - 'callback' => array( $artist_url_import, 'approve' ), - 'permission_callback' => array( ArtistUrlImport::class, 'permission_admin' ), - 'args' => array( - 'id' => array( - 'required' => true, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - ), - 'artist_term_id' => array( - 'type' => 'integer', - 'sanitize_callback' => 'absint', - ), - 'artist_name' => array( - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - ), - 'schedule_interval' => array( - 'type' => 'string', - 'sanitize_callback' => 'sanitize_key', - ), - ), - ) - ); - - register_rest_route( - API_NAMESPACE, - '/artist-url/(?P<id>\d+)/reject', - array( - 'methods' => 'POST', - 'callback' => array( $artist_url_import, 'reject' ), - 'permission_callback' => array( ArtistUrlImport::class, 'permission_admin' ), - 'args' => array( - 'id' => array( - 'required' => true, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - ), - 'reason' => array( - 'type' => 'string', - 'sanitize_callback' => 'sanitize_textarea_field', - ), - ), - ) - ); } add_action( 'rest_api_init', __NAMESPACE__ . '\\register_routes' ); diff --git a/inc/Core/ArtistUrlSubmissionsTable.php b/inc/Core/ArtistUrlSubmissionsTable.php deleted file mode 100644 index d8845780..00000000 --- a/inc/Core/ArtistUrlSubmissionsTable.php +++ /dev/null @@ -1,371 +0,0 @@ -<?php -/** - * Artist URL Submissions Table - * - * Stores the moderation queue for URL-based artist tour imports submitted - * via the event-submission block (extrachill-events#320). A row is - * inserted when a logged-in user submits an artist tour URL; an admin - * reviews the row and either approves it — at which point a Data Machine - * pipeline + flow are created via `datamachine/create-flow` — or rejects - * it. Failed scrapes are recorded with `status = 'scraping_failed'` so - * admins can review URLs that need handler-format expansion. - * - * Table lives under `$wpdb->base_prefix` (network-scoped) so the - * moderation queue is global across the multisite, matching the prompt's - * dedupe contract ("if user A submits the same URL as user B already - * did, the second submission returns 'this URL is already being - * tracked'"). DME itself is per-site activated, so we install lazily on - * `plugins_loaded` via a version option check — same shape Data Machine - * core uses for its own network-scoped tables. - * - * @package DataMachineEvents\Core - * @since 0.40.0 - */ - -namespace DataMachineEvents\Core; - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -class ArtistUrlSubmissionsTable { - - /** - * Schema version. Bump when CREATE TABLE definition changes. - */ - const SCHEMA_VERSION = '1'; - - /** - * Site option key that stores the installed schema version. - */ - const VERSION_OPTION = 'datamachine_events_artist_url_submissions_db_version'; - - /** - * Submission statuses. - */ - const STATUS_PENDING_REVIEW = 'pending_review'; - const STATUS_APPROVED = 'approved'; - const STATUS_REJECTED = 'rejected'; - const STATUS_SCRAPING_FAILED = 'scraping_failed'; - - /** - * Get the full table name. Uses base prefix so the moderation queue - * is shared across the multisite network (see class docblock). - * - * @return string - */ - public static function table_name(): string { - global $wpdb; - return $wpdb->base_prefix . 'artist_url_submissions'; - } - - /** - * Install / upgrade the table via dbDelta. Idempotent — safe to call - * on every request once the version guard is in place. - */ - public static function create_table(): void { - global $wpdb; - - $table = self::table_name(); - $charset = $wpdb->get_charset_collate(); - - $sql = "CREATE TABLE {$table} ( - id bigint(20) unsigned NOT NULL AUTO_INCREMENT, - user_id bigint(20) unsigned NULL, - contact_email varchar(255) NULL, - contact_name varchar(255) NULL, - url varchar(2048) NOT NULL, - url_hash char(64) NOT NULL, - suggested_artist_name varchar(255) NULL, - suggested_artist_term_id bigint(20) unsigned NULL, - detected_format varchar(64) NULL, - events_found_count int unsigned NOT NULL DEFAULT 0, - status varchar(32) NOT NULL DEFAULT 'pending_review', - admin_notes text NULL, - rejection_reason text NULL, - pipeline_id bigint(20) unsigned NULL, - flow_id bigint(20) unsigned NULL, - artist_term_id bigint(20) unsigned NULL, - created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, - reviewed_at datetime NULL, - reviewed_by bigint(20) unsigned NULL, - PRIMARY KEY (id), - UNIQUE KEY url_hash (url_hash), - KEY idx_status (status, created_at), - KEY idx_user (user_id, created_at) - ) {$charset};"; - - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - dbDelta( $sql ); - - update_site_option( self::VERSION_OPTION, self::SCHEMA_VERSION ); - } - - /** - * Check whether the installed schema is current. Called on every - * request via the install hook below; only triggers dbDelta when the - * stored version mismatches the class constant. - */ - public static function maybe_install(): void { - $installed = get_site_option( self::VERSION_OPTION, '' ); - if ( self::SCHEMA_VERSION === $installed ) { - return; - } - self::create_table(); - } - - /** - * Check if the table exists. - * - * @return bool - */ - public static function table_exists(): bool { - global $wpdb; - $table = self::table_name(); - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared - return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; - } - - /** - * Normalize a URL for hashing/dedupe. - * - * Lowercases scheme + host, strips fragment, trims trailing slash - * (except for root paths), and removes default ports. - * - * @param string $url Raw URL. - * @return string Normalized URL, or empty string if the input is not parseable. - */ - public static function normalize_url( string $url ): string { - $url = trim( $url ); - if ( '' === $url ) { - return ''; - } - - $parts = wp_parse_url( $url ); - if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { - return ''; - } - - $scheme = strtolower( $parts['scheme'] ); - $host = strtolower( $parts['host'] ); - $port = $parts['port'] ?? ''; - $path = $parts['path'] ?? '/'; - $query = $parts['query'] ?? ''; - - // Strip default ports. - if ( ( 'http' === $scheme && 80 === (int) $port ) || ( 'https' === $scheme && 443 === (int) $port ) ) { - $port = ''; - } - - // Trim trailing slash on non-root paths. - if ( strlen( $path ) > 1 && '/' === substr( $path, -1 ) ) { - $path = rtrim( $path, '/' ); - } - if ( '' === $path ) { - $path = '/'; - } - - $normalized = $scheme . '://' . $host; - if ( '' !== (string) $port ) { - $normalized .= ':' . $port; - } - $normalized .= $path; - if ( '' !== $query ) { - $normalized .= '?' . $query; - } - - return $normalized; - } - - /** - * Compute the dedupe hash for a normalized URL. - * - * @param string $normalized_url URL already passed through normalize_url(). - * @return string sha256 hex. - */ - public static function url_hash( string $normalized_url ): string { - return hash( 'sha256', $normalized_url ); - } - - /** - * Look up a submission by its URL hash. - * - * @param string $url_hash sha256 hex. - * @return array|null Submission row as assoc array, or null. - */ - public static function find_by_hash( string $url_hash ): ?array { - global $wpdb; - $table = self::table_name(); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $row = $wpdb->get_row( - $wpdb->prepare( "SELECT * FROM {$table} WHERE url_hash = %s LIMIT 1", $url_hash ), - ARRAY_A - ); - - return $row ?: null; - } - - /** - * Look up a submission by ID. - * - * @param int $id Submission ID. - * @return array|null - */ - public static function get( int $id ): ?array { - global $wpdb; - $table = self::table_name(); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $row = $wpdb->get_row( - $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d LIMIT 1", $id ), - ARRAY_A - ); - - return $row ?: null; - } - - /** - * Insert a new submission row. Returns the new ID, or null on failure. - * - * @param array $data Row data. - * @return int|null - */ - public static function insert( array $data ): ?int { - global $wpdb; - $table = self::table_name(); - - $defaults = array( - 'user_id' => null, - 'contact_email' => null, - 'contact_name' => null, - 'url' => '', - 'url_hash' => '', - 'suggested_artist_name' => null, - 'suggested_artist_term_id' => null, - 'detected_format' => null, - 'events_found_count' => 0, - 'status' => self::STATUS_PENDING_REVIEW, - 'admin_notes' => null, - 'rejection_reason' => null, - 'pipeline_id' => null, - 'flow_id' => null, - 'artist_term_id' => null, - ); - - $row = array_merge( $defaults, array_intersect_key( $data, $defaults ) ); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $result = $wpdb->insert( $table, $row ); - if ( false === $result ) { - return null; - } - - return (int) $wpdb->insert_id; - } - - /** - * Update a submission row by ID. - * - * @param int $id Submission ID. - * @param array $data Columns to update. - * @return bool - */ - public static function update( int $id, array $data ): bool { - global $wpdb; - $table = self::table_name(); - - $allowed = array( - 'status', - 'admin_notes', - 'rejection_reason', - 'pipeline_id', - 'flow_id', - 'artist_term_id', - 'suggested_artist_name', - 'suggested_artist_term_id', - 'detected_format', - 'events_found_count', - 'reviewed_at', - 'reviewed_by', - ); - - $update = array_intersect_key( $data, array_flip( $allowed ) ); - if ( empty( $update ) ) { - return false; - } - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $result = $wpdb->update( $table, $update, array( 'id' => $id ) ); - return false !== $result; - } - - /** - * List submissions filtered by status. - * - * @param string $status One of the STATUS_* constants, or 'all'. - * @param int $per_page Page size. - * @param int $offset Offset for pagination. - * @return array<int, array> - */ - public static function list_by_status( string $status, int $per_page = 50, int $offset = 0 ): array { - global $wpdb; - $table = self::table_name(); - - $per_page = max( 1, min( 500, $per_page ) ); - $offset = max( 0, $offset ); - - if ( 'all' === $status ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $rows = $wpdb->get_results( - $wpdb->prepare( - "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d OFFSET %d", - $per_page, - $offset - ), - ARRAY_A - ); - } else { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $rows = $wpdb->get_results( - $wpdb->prepare( - "SELECT * FROM {$table} WHERE status = %s ORDER BY created_at DESC LIMIT %d OFFSET %d", - $status, - $per_page, - $offset - ), - ARRAY_A - ); - } - - return $rows ?: array(); - } - - /** - * Count submissions by status. Returns an associative array keyed by - * status with row counts. - * - * @return array<string,int> - */ - public static function counts_by_status(): array { - global $wpdb; - $table = self::table_name(); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $rows = $wpdb->get_results( - "SELECT status, COUNT(*) AS c FROM {$table} GROUP BY status", - ARRAY_A - ); - - $out = array( - self::STATUS_PENDING_REVIEW => 0, - self::STATUS_APPROVED => 0, - self::STATUS_REJECTED => 0, - self::STATUS_SCRAPING_FAILED => 0, - ); - foreach ( (array) $rows as $row ) { - $out[ $row['status'] ] = (int) $row['c']; - } - return $out; - } -}