diff --git a/data-machine.php b/data-machine.php index 701ea06b3..60763c1af 100644 --- a/data-machine.php +++ b/data-machine.php @@ -173,6 +173,7 @@ function () { require_once __DIR__ . '/inc/Abilities/Publish/PublishWordPressAbility.php'; require_once __DIR__ . '/inc/Abilities/Publish/SendEmailAbility.php'; require_once __DIR__ . '/inc/Abilities/Update/UpdateWordPressAbility.php'; + require_once __DIR__ . '/inc/Abilities/Handler/TestHandlerAbility.php'; // Defer ability instantiation to init so translations are loaded. add_action( 'init', function () { new \DataMachine\Abilities\AuthAbilities(); @@ -228,6 +229,7 @@ function () { new \DataMachine\Abilities\Publish\PublishWordPressAbility(); new \DataMachine\Abilities\Publish\SendEmailAbility(); new \DataMachine\Abilities\Update\UpdateWordPressAbility(); + new \DataMachine\Abilities\Handler\TestHandlerAbility(); } ); // Clean up identity index rows when posts are permanently deleted. diff --git a/inc/Abilities/Handler/TestHandlerAbility.php b/inc/Abilities/Handler/TestHandlerAbility.php new file mode 100644 index 000000000..97b9a588f --- /dev/null +++ b/inc/Abilities/Handler/TestHandlerAbility.php @@ -0,0 +1,305 @@ +registerAbility(); + self::$registered = true; + } + + private function registerAbility(): void { + $register_callback = function () { + wp_register_ability( + 'datamachine/test-handler', + array( + 'label' => __( 'Test Handler', 'data-machine' ), + 'description' => __( 'Dry-run any fetch handler with a config and return packet summaries.', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'handler_slug' => array( + 'type' => 'string', + 'description' => __( 'Handler slug to test (required unless flow_id provided)', 'data-machine' ), + ), + 'config' => array( + 'type' => 'object', + 'description' => __( 'Handler configuration overrides', 'data-machine' ), + ), + 'flow_id' => array( + 'type' => 'integer', + 'description' => __( 'Pull handler slug and config from an existing flow', 'data-machine' ), + ), + 'limit' => array( + 'type' => 'integer', + 'description' => __( 'Max packets to return (default 5)', 'data-machine' ), + 'default' => 5, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'handler_slug' => array( 'type' => 'string' ), + 'handler_label' => array( 'type' => 'string' ), + 'config_used' => array( 'type' => 'object' ), + 'packets' => array( 'type' => 'array' ), + 'packet_count' => array( 'type' => 'integer' ), + 'warnings' => array( 'type' => 'array' ), + 'execution_time_ms' => array( 'type' => 'number' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'execute' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + }; + + if ( doing_action( 'wp_abilities_api_init' ) ) { + $register_callback(); + } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { + add_action( 'wp_abilities_api_init', $register_callback ); + } + } + + /** + * Permission callback. + * + * @return bool + */ + public function checkPermission(): bool { + return PermissionHelper::can_manage(); + } + + /** + * Execute the test handler ability. + * + * @param array $input Input parameters. + * @return array Result with packet summaries. + */ + public function execute( array $input ): array { + $handler_slug = $input['handler_slug'] ?? null; + $config = $input['config'] ?? array(); + $flow_id = isset( $input['flow_id'] ) ? (int) $input['flow_id'] : null; + $limit = (int) ( $input['limit'] ?? 5 ); + $warnings = array(); + + // Resolve from flow if flow_id provided. + if ( $flow_id ) { + $resolved = $this->resolveFromFlow( $flow_id ); + + if ( ! $resolved['success'] ) { + return $resolved; + } + + $handler_slug = $resolved['handler_slug']; + + // Flow config is the base; explicit config overrides. + $config = array_merge( $resolved['config'], $config ); + } + + if ( empty( $handler_slug ) ) { + return array( + 'success' => false, + 'error' => 'handler_slug is required (provide it directly or via --flow)', + ); + } + + $abilities = new HandlerAbilities(); + $info = $abilities->getHandler( $handler_slug ); + + if ( ! $info ) { + return array( + 'success' => false, + 'error' => sprintf( 'Handler "%s" not found. Use --list to see available handlers.', $handler_slug ), + ); + } + + $handler_label = $info['label'] ?? $handler_slug; + $handler_class = $info['class'] ?? null; + + if ( ! $handler_class || ! class_exists( $handler_class ) ) { + return array( + 'success' => false, + 'error' => sprintf( 'Handler class for "%s" not found or not loaded.', $handler_slug ), + ); + } + + // Apply defaults to fill in missing config values. + $config = $abilities->applyDefaults( $handler_slug, $config ); + + // Inject required internal keys for direct execution. + if ( ! isset( $config['flow_step_id'] ) ) { + $config['flow_step_id'] = 'test_' . wp_generate_uuid4(); + } + if ( ! isset( $config['flow_id'] ) ) { + $config['flow_id'] = 'direct'; + } + + $handler = new $handler_class(); + $start_ms = microtime( true ); + + try { + $packets = $handler->get_fetch_data( 'direct', $config, null ); + } catch ( \Throwable $e ) { + return array( + 'success' => false, + 'handler_slug' => $handler_slug, + 'handler_label' => $handler_label, + 'config_used' => $config, + 'error' => $e->getMessage(), + 'execution_time_ms' => round( ( microtime( true ) - $start_ms ) * 1000, 1 ), + ); + } + + $elapsed_ms = round( ( microtime( true ) - $start_ms ) * 1000, 1 ); + + if ( ! is_array( $packets ) ) { + $packets = array(); + } + + $total_count = count( $packets ); + + if ( $limit > 0 && $total_count > $limit ) { + $packets = array_slice( $packets, 0, $limit ); + $warnings[] = sprintf( 'Showing %d of %d packets (use --limit to see more).', $limit, $total_count ); + } + + // Convert DataPacket objects to summary arrays. + $packet_summaries = array(); + foreach ( $packets as $packet ) { + $packet_summaries[] = $this->summarizePacket( $packet ); + } + + return array( + 'success' => true, + 'handler_slug' => $handler_slug, + 'handler_label' => $handler_label, + 'config_used' => $config, + 'packets' => $packet_summaries, + 'packet_count' => $total_count, + 'warnings' => $warnings, + 'execution_time_ms' => $elapsed_ms, + ); + } + + /** + * Resolve handler slug and config from an existing flow. + * + * @param int $flow_id Flow ID. + * @return array Result with handler_slug and config, or error. + */ + private function resolveFromFlow( int $flow_id ): array { + $db_flows = new Flows(); + $flow = $db_flows->get_flow( $flow_id ); + + if ( ! $flow ) { + return array( + 'success' => false, + 'error' => sprintf( 'Flow %d not found.', $flow_id ), + ); + } + + $flow_config = $flow['flow_config'] ?? array(); + + if ( empty( $flow_config ) ) { + return array( + 'success' => false, + 'error' => sprintf( 'Flow %d has no steps configured.', $flow_id ), + ); + } + + // Find the first fetch or event_import step. + $fetch_step_types = array( 'fetch', 'event_import' ); + + foreach ( $flow_config as $step ) { + $step_type = $step['step_type'] ?? ''; + + if ( ! in_array( $step_type, $fetch_step_types, true ) ) { + continue; + } + + $handler_slugs = $step['handler_slugs'] ?? array(); + + if ( empty( $handler_slugs ) ) { + continue; + } + + $slug = $handler_slugs[0]; + $handler_configs = $step['handler_configs'] ?? array(); + $handler_config = $handler_configs[ $slug ] ?? array(); + + return array( + 'success' => true, + 'handler_slug' => $slug, + 'config' => $handler_config, + ); + } + + return array( + 'success' => false, + 'error' => sprintf( 'Flow %d has no fetch or event_import step with a handler.', $flow_id ), + ); + } + + /** + * Convert a DataPacket to a summary array for output. + * + * @param mixed $packet DataPacket instance. + * @return array Summary with title, content_preview, metadata, source_url. + */ + private function summarizePacket( $packet ): array { + // DataPacket uses addTo() to serialize — extract via a temporary array. + $serialized = $packet->addTo( array() ); + $entry = $serialized[0] ?? array(); + + $data = $entry['data'] ?? array(); + $metadata = $entry['metadata'] ?? array(); + + $title = $data['title'] ?? ''; + $body = $data['body'] ?? ''; + $preview = mb_substr( $body, 0, 200 ); + + if ( mb_strlen( $body ) > 200 ) { + $preview .= '...'; + } + + return array( + 'title' => $title, + 'content_preview' => $preview, + 'metadata' => $metadata, + 'source_url' => $metadata['source_url'] ?? '', + ); + } +} diff --git a/inc/Cli/Bootstrap.php b/inc/Cli/Bootstrap.php index e154d900a..c8bb4ab5e 100644 --- a/inc/Cli/Bootstrap.php +++ b/inc/Cli/Bootstrap.php @@ -41,6 +41,7 @@ WP_CLI::add_command( 'datamachine step-types', Commands\StepTypesCommand::class ); WP_CLI::add_command( 'datamachine processed-items', Commands\ProcessedItemsCommand::class ); WP_CLI::add_command( 'datamachine retention', Commands\RetentionCommand::class ); +WP_CLI::add_command( 'datamachine test', Commands\TestCommand::class ); // Aliases for AI agent compatibility (singular/plural variants). WP_CLI::add_command( 'datamachine setting', Commands\SettingsCommand::class ); diff --git a/inc/Cli/Commands/TestCommand.php b/inc/Cli/Commands/TestCommand.php new file mode 100644 index 000000000..b217ecfa5 --- /dev/null +++ b/inc/Cli/Commands/TestCommand.php @@ -0,0 +1,381 @@ +] + * : Handler slug to test. + * + * [--config=] + * : Handler config as JSON string. + * + * [--flow=] + * : Pull handler and config from an existing flow ID. + * + * [--limit=] + * : Max packets to return. + * --- + * default: 5 + * --- + * + * [--list] + * : List all available fetch handlers. + * + * [--describe] + * : Show config fields for the given handler. + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - yaml + * --- + * + * ## EXAMPLES + * + * wp datamachine test --list + * wp datamachine test ticketmaster --describe + * wp datamachine test ticketmaster --config='{"lat":32.7,"lng":-79.9,"radius":50}' + * wp datamachine test rss --config='{"feed_url":"https://example.com/feed"}' + * wp datamachine test --flow=42 + * wp datamachine test ticketmaster --config='...' --limit=3 --format=json + * + * @when after_wp_load + */ + public function __invoke( array $args, array $assoc_args ): void { + $handler_slug = $args[0] ?? null; + $format = $assoc_args['format'] ?? 'table'; + + // --list: show available handlers. + if ( isset( $assoc_args['list'] ) || ( ! $handler_slug && ! isset( $assoc_args['flow'] ) ) ) { + $this->listHandlers( $assoc_args ); + return; + } + + // --describe: show config fields for a handler. + if ( isset( $assoc_args['describe'] ) ) { + if ( ! $handler_slug ) { + WP_CLI::error( 'Handler slug is required with --describe.' ); + return; + } + $this->describeHandler( $handler_slug, $assoc_args ); + return; + } + + // Run the test. + $this->runTest( $handler_slug, $assoc_args ); + } + + /** + * List all available fetch handlers. + * + * @param array $assoc_args Command arguments. + */ + private function listHandlers( array $assoc_args ): void { + $format = $assoc_args['format'] ?? 'table'; + $ability = new HandlerAbilities(); + $handlers = $ability->getAllHandlers(); + + if ( empty( $handlers ) ) { + WP_CLI::warning( 'No handlers registered.' ); + return; + } + + // Filter to fetch-type handlers (fetch + event_import). + $fetch_types = array( 'fetch', 'event_import' ); + $items = array(); + + foreach ( $handlers as $slug => $handler ) { + $handler_type = $handler['type'] ?? $handler['step_type'] ?? ''; + if ( ! in_array( $handler_type, $fetch_types, true ) ) { + continue; + } + + $items[] = array( + 'slug' => $slug, + 'label' => $handler['label'] ?? '', + 'type' => $handler_type, + ); + } + + if ( empty( $items ) ) { + WP_CLI::warning( 'No fetch handlers registered.' ); + return; + } + + if ( 'json' === $format ) { + WP_CLI::line( wp_json_encode( $items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); + return; + } + + $fields = array( 'slug', 'label', 'type' ); + $this->format_items( $items, $fields, $assoc_args, 'slug' ); + + WP_CLI::log( sprintf( 'Total: %d fetch handler(s).', count( $items ) ) ); + } + + /** + * Describe config fields for a handler. + * + * @param string $handler_slug Handler slug. + * @param array $assoc_args Command arguments. + */ + private function describeHandler( string $handler_slug, array $assoc_args ): void { + $format = $assoc_args['format'] ?? 'table'; + $ability = new HandlerAbilities(); + $info = $ability->getHandler( $handler_slug ); + + if ( ! $info ) { + WP_CLI::error( sprintf( 'Handler "%s" not found.', $handler_slug ) ); + return; + } + + $fields = $ability->getConfigFields( $handler_slug ); + + if ( 'json' === $format ) { + WP_CLI::line( + wp_json_encode( + array( + 'handler_slug' => $handler_slug, + 'label' => $info['label'] ?? $handler_slug, + 'type' => $info['type'] ?? $info['step_type'] ?? '', + 'fields' => $fields, + ), + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES + ) + ); + return; + } + + WP_CLI::log( '' ); + WP_CLI::log( sprintf( 'Handler: %s', $handler_slug ) ); + WP_CLI::log( sprintf( 'Label: %s', $info['label'] ?? $handler_slug ) ); + WP_CLI::log( sprintf( 'Type: %s', $info['type'] ?? $info['step_type'] ?? '' ) ); + WP_CLI::log( '' ); + + if ( empty( $fields ) ) { + WP_CLI::warning( 'No config fields defined for this handler.' ); + return; + } + + $items = array(); + foreach ( $fields as $key => $field ) { + $default_val = isset( $field['default'] ) ? wp_json_encode( $field['default'] ) : ''; + $items[] = array( + 'key' => $key, + 'type' => $field['type'] ?? 'text', + 'label' => $field['label'] ?? $key, + 'required' => ! empty( $field['required'] ) ? 'yes' : 'no', + 'default' => $default_val, + ); + } + + $field_columns = array( 'key', 'type', 'label', 'required', 'default' ); + $this->format_items( $items, $field_columns, $assoc_args, 'key' ); + } + + /** + * Run the handler test. + * + * @param string|null $handler_slug Handler slug (null if using --flow). + * @param array $assoc_args Command arguments. + */ + private function runTest( ?string $handler_slug, array $assoc_args ): void { + $format = $assoc_args['format'] ?? 'table'; + $config_json = $assoc_args['config'] ?? null; + $flow_id = isset( $assoc_args['flow'] ) ? (int) $assoc_args['flow'] : null; + $limit = (int) ( $assoc_args['limit'] ?? 5 ); + + $config = array(); + if ( $config_json ) { + $config = json_decode( $config_json, true ); + if ( ! is_array( $config ) ) { + WP_CLI::error( 'Invalid JSON in --config. Provide a valid JSON object.' ); + return; + } + } + + // Build ability input. + $input = array( + 'limit' => $limit, + ); + + if ( $handler_slug ) { + $input['handler_slug'] = $handler_slug; + } + + if ( ! empty( $config ) ) { + $input['config'] = $config; + } + + if ( $flow_id ) { + $input['flow_id'] = $flow_id; + } + + $ability = new TestHandlerAbility(); + $result = $ability->execute( $input ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Test failed.' ); + return; + } + + // JSON output — dump everything. + if ( 'json' === $format ) { + WP_CLI::line( wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); + return; + } + + // Table output — rich formatting. + $this->renderTableOutput( $result ); + } + + /** + * Render rich table output for test results. + * + * @param array $result Ability result. + */ + private function renderTableOutput( array $result ): void { + $handler_slug = $result['handler_slug']; + $handler_label = $result['handler_label']; + $config_used = $result['config_used'] ?? array(); + $packets = $result['packets'] ?? array(); + $packet_count = $result['packet_count'] ?? 0; + $elapsed_ms = $result['execution_time_ms'] ?? 0; + $warnings = $result['warnings'] ?? array(); + + // Header. + WP_CLI::log( '' ); + WP_CLI::log( sprintf( 'Handler: %s', $handler_slug ) ); + WP_CLI::log( sprintf( 'Label: %s', $handler_label ) ); + + // Config summary (key=value pairs). + $config_parts = array(); + foreach ( $config_used as $key => $value ) { + // Skip internal keys. + if ( in_array( $key, array( 'flow_step_id', 'flow_id' ), true ) ) { + continue; + } + if ( is_array( $value ) || is_object( $value ) ) { + $config_parts[] = $key . '=' . wp_json_encode( $value ); + } else { + $config_parts[] = $key . '=' . $value; + } + } + if ( ! empty( $config_parts ) ) { + WP_CLI::log( sprintf( 'Config: %s', implode( ', ', $config_parts ) ) ); + } + + WP_CLI::log( '' ); + + if ( empty( $packets ) ) { + WP_CLI::warning( 'No packets returned.' ); + WP_CLI::log( sprintf( 'Execution time: %ss', round( $elapsed_ms / 1000, 1 ) ) ); + return; + } + + $count_label = count( $packets ); + if ( $packet_count > $count_label ) { + $count_label .= ' of ' . $packet_count; + } + + WP_CLI::log( sprintf( '── Results: %s items ──', $count_label ) ); + WP_CLI::log( '' ); + + // Build table rows. + $items = array(); + $all_metadata_keys = array(); + + foreach ( $packets as $index => $packet ) { + $title = $packet['title'] ?? '(untitled)'; + $source_url = $packet['source_url'] ?? ''; + $metadata = $packet['metadata'] ?? array(); + + // Extract domain from source_url. + $source_display = ''; + if ( $source_url ) { + $parsed = wp_parse_url( $source_url ); + $source_display = $parsed['host'] ?? $source_url; + } + + $items[] = array( + '#' => $index + 1, + 'title' => mb_substr( $title, 0, 60 ), + 'source' => $source_display, + ); + + // Collect metadata keys (excluding internal ones). + $internal_keys = array( 'source_type', 'pipeline_id', 'flow_id', 'handler', 'dedup_key' ); + foreach ( array_keys( $metadata ) as $mk ) { + if ( ! in_array( $mk, $internal_keys, true ) ) { + $all_metadata_keys[ $mk ] = true; + } + } + } + + $fields = array( '#', 'title', 'source' ); + $this->format_items( $items, $fields, array( 'format' => 'table' ) ); + + WP_CLI::log( '' ); + + // Metadata keys summary. + if ( ! empty( $all_metadata_keys ) ) { + WP_CLI::log( sprintf( 'Metadata keys: %s', implode( ', ', array_keys( $all_metadata_keys ) ) ) ); + } + + // Warnings. + foreach ( $warnings as $warning ) { + WP_CLI::warning( $warning ); + } + + WP_CLI::log( sprintf( 'Execution time: %ss', round( $elapsed_ms / 1000, 1 ) ) ); + } +}