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 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( '/]+property=[\"\']og:title[\"\'][^>]+content=[\"\']([^\"\']+)[\"\']/i', $html, $m ) ) {
- $candidates[] = $this->stripSiteTokens( html_entity_decode( $m[1] ) );
- }
- // 2b.
- if ( preg_match( '/([^<]+)<\/title>/i', $html, $m ) ) {
- $candidates[] = $this->stripSiteTokens( html_entity_decode( $m[1] ) );
- }
- // 2c. first
- if ( preg_match( '/]*>(.*?)<\/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 @@
- '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 '';
- echo '
' . esc_html__( 'Artist URL Imports', 'data-machine-events' ) . '
';
- echo '
' . 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' ) . '
';
-
- if ( 'approved' === $flash ) {
- echo '
' . esc_html__( 'Submission approved and pipeline created.', 'data-machine-events' ) . '
';
- } elseif ( 'rejected' === $flash ) {
- echo '
' . esc_html__( 'Submission rejected.', 'data-machine-events' ) . '
';
- } elseif ( 'error' === $flash ) {
- $msg = isset( $_GET['msg'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['msg'] ) ) : __( 'Action failed.', 'data-machine-events' );
- echo '
';
- }
-
- // Status tabs.
- echo '
';
- $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 '- ';
- echo esc_html( $tab_label ) . ' (' . esc_html( (string) $count ) . ')';
- echo '' . ( $i < $last_idx ? ' |' : '' ) . '
';
- ++$i;
- }
- echo '
';
- echo '
';
-
- // Table.
- if ( empty( $submissions ) ) {
- echo '
' . esc_html__( 'No submissions in this status.', 'data-machine-events' ) . '
';
- echo '
';
- return;
- }
-
- echo '
';
- echo '';
- echo '| ' . esc_html__( 'ID', 'data-machine-events' ) . ' | ';
- echo '' . esc_html__( 'URL', 'data-machine-events' ) . ' | ';
- echo '' . esc_html__( 'Submitter', 'data-machine-events' ) . ' | ';
- echo '' . esc_html__( 'Suggested artist', 'data-machine-events' ) . ' | ';
- echo '' . esc_html__( 'Events found', 'data-machine-events' ) . ' | ';
- echo '' . esc_html__( 'Detected format', 'data-machine-events' ) . ' | ';
- echo '' . esc_html__( 'Submitted', 'data-machine-events' ) . ' | ';
- echo '' . esc_html__( 'Actions', 'data-machine-events' ) . ' | ';
- echo '
';
-
- foreach ( $submissions as $row ) {
- $this->render_row( $row, $status );
- }
-
- echo '
';
- echo '';
- }
-
- 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 '';
- echo '| ' . esc_html( (string) $row['id'] ) . ' | ';
- echo '' . esc_html( $row['url'] ) . ' | ';
- echo '' . esc_html( $submitter ) . ' | ';
- echo '' . esc_html( $suggested ?: '—' ) . ' | ';
- echo '' . esc_html( (string) (int) $row['events_found_count'] ) . ' | ';
- echo '' . esc_html( (string) ( $row['detected_format'] ?? '' ) ) . ' | ';
- echo '' . esc_html( (string) $row['created_at'] ) . ' | ';
- echo '';
-
- if ( ArtistUrlSubmissionsTable::STATUS_PENDING_REVIEW === $status_context ) {
- $this->render_approve_form( (int) $row['id'], $row );
- echo ' ';
- $this->render_reject_form( (int) $row['id'] );
- } elseif ( ArtistUrlSubmissionsTable::STATUS_APPROVED === $status_context ) {
- if ( ! empty( $row['pipeline_id'] ) ) {
- echo '';
- echo esc_html__( 'View pipeline', 'data-machine-events' );
- echo '';
- } 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 '' . esc_html__( 'No actions — investigate handler coverage.', 'data-machine-events' ) . '';
- }
-
- echo ' | ';
- echo '
';
- }
-
- 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'] ?? '' );
- ?>
-
-
-
- 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 @@
-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\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\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 @@
-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
- */
- 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
- */
- 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;
- }
-}