Array of mappings. */ function robotstxt_docmd_get_all_mappings(): array { $args = array( 'post_type' => 'robotstxt_map', 'posts_per_page' => -1, 'post_status' => 'publish', 'orderby' => 'title', 'order' => 'ASC', ); $query = new WP_Query( $args ); $mappings = array(); foreach ( $query->posts as $post ) { if ( ! ( $post instanceof WP_Post ) ) { continue; } $mapping = robotstxt_docmd_get_mapping( $post->ID ); if ( $mapping ) { $mappings[] = $mapping; } } wp_reset_postdata(); return $mappings; } /** * Get single mapping * * @since 1.0.0 * * @param int $mapping_id Mapping post ID. * @return MappingData|null Mapping data or null. */ function robotstxt_docmd_get_mapping( int $mapping_id ): ?array { $post = get_post( $mapping_id ); if ( ! $post || 'robotstxt_map' !== $post->post_type ) { return null; } $repo_owner_raw = get_post_meta( $mapping_id, '_robotstxt_docmd_repo_owner', true ); $repo_name_raw = get_post_meta( $mapping_id, '_robotstxt_docmd_repo_name', true ); $file_path_raw = get_post_meta( $mapping_id, '_robotstxt_docmd_file_path', true ); $branch_raw = get_post_meta( $mapping_id, '_robotstxt_docmd_branch', true ); $repo_owner = is_string( $repo_owner_raw ) ? $repo_owner_raw : ''; $repo_name = is_string( $repo_name_raw ) ? $repo_name_raw : ''; $file_path = is_string( $file_path_raw ) ? $file_path_raw : ''; $branch = is_string( $branch_raw ) && '' !== $branch_raw ? $branch_raw : 'main'; $post_type_raw = get_post_meta( $mapping_id, '_robotstxt_docmd_target_post_type', true ); $sync_frequency_raw = get_post_meta( $mapping_id, '_robotstxt_docmd_sync_frequency', true ); $sync_status_raw = get_post_meta( $mapping_id, '_robotstxt_docmd_sync_status', true ); $last_sync_raw = get_post_meta( $mapping_id, '_robotstxt_docmd_last_sync', true ); $target_post_id_raw = get_post_meta( $mapping_id, '_robotstxt_docmd_target_post_id', true ); $target_author_raw = get_post_meta( $mapping_id, '_robotstxt_docmd_target_author', true ); $target_parent_raw = get_post_meta( $mapping_id, '_robotstxt_docmd_target_parent', true ); $target_order_raw = get_post_meta( $mapping_id, '_robotstxt_docmd_target_order', true ); return array( 'id' => $mapping_id, 'title' => $post->post_title, 'repo_owner' => $repo_owner, 'repo_name' => $repo_name, 'file_path' => $file_path, 'branch' => $branch, 'github_url' => sprintf( 'https://github.com/%s/%s/blob/%s/%s', $repo_owner, $repo_name, $branch, $file_path ), 'target_post_type' => is_string( $post_type_raw ) ? $post_type_raw : '', 'target_post_id' => is_numeric( $target_post_id_raw ) ? absint( $target_post_id_raw ) : 0, 'target_author' => is_numeric( $target_author_raw ) ? absint( $target_author_raw ) : 0, 'target_parent' => is_numeric( $target_parent_raw ) ? absint( $target_parent_raw ) : 0, 'target_order' => is_numeric( $target_order_raw ) ? absint( $target_order_raw ) : 0, 'sync_enabled' => (bool) get_post_meta( $mapping_id, '_robotstxt_docmd_sync_enabled', true ), 'sync_frequency' => is_string( $sync_frequency_raw ) ? $sync_frequency_raw : 'daily', 'sync_status' => is_string( $sync_status_raw ) && '' !== $sync_status_raw ? $sync_status_raw : 'never', 'last_sync' => is_string( $last_sync_raw ) ? $last_sync_raw : '', ); } /** * Create mapping * * @since 1.0.0 * * @param array $data Mapping input data. * @phpstan-param MappingInput $data * @return int|WP_Error Mapping ID or error. */ function robotstxt_docmd_create_mapping( array $data ): int|WP_Error { // Check for duplicates. if ( robotstxt_docmd_is_file_mapped( $data['repo_owner'], $data['repo_name'], $data['file_path'], $data['branch'] ) ) { return new WP_Error( 'duplicate_mapping', __( 'This file is already mapped', 'robotstxt-documentation-markdown' ) ); } // Create mapping post. $post_id = wp_insert_post( array( 'post_title' => $data['title'], 'post_type' => 'robotstxt_map', 'post_status' => 'publish', ), true ); if ( is_wp_error( $post_id ) ) { return $post_id; } // Save metadata. update_post_meta( $post_id, '_robotstxt_docmd_repo_owner', $data['repo_owner'] ); update_post_meta( $post_id, '_robotstxt_docmd_repo_name', $data['repo_name'] ); update_post_meta( $post_id, '_robotstxt_docmd_file_path', $data['file_path'] ); update_post_meta( $post_id, '_robotstxt_docmd_branch', $data['branch'] ); update_post_meta( $post_id, '_robotstxt_docmd_target_post_type', $data['target_post_type'] ); update_post_meta( $post_id, '_robotstxt_docmd_target_author', $data['target_author'] ); update_post_meta( $post_id, '_robotstxt_docmd_target_parent', $data['target_parent'] ); update_post_meta( $post_id, '_robotstxt_docmd_target_order', $data['target_order'] ); update_post_meta( $post_id, '_robotstxt_docmd_sync_enabled', $data['sync_enabled'] ? 1 : 0 ); update_post_meta( $post_id, '_robotstxt_docmd_sync_frequency', $data['sync_frequency'] ); if ( ! empty( $data['target_post_id'] ) ) { update_post_meta( $post_id, '_robotstxt_docmd_target_post_id', $data['target_post_id'] ); } // Schedule cron if enabled. if ( $data['sync_enabled'] && 'never' !== $data['sync_frequency'] ) { robotstxt_docmd_schedule_mapping_sync( $post_id, $data['sync_frequency'] ); } return $post_id; } /** * Update mapping * * @since 1.0.0 * * @param int $mapping_id Mapping ID. * @param array $data Mapping input data. * @phpstan-param MappingInput $data * @return bool True on success. */ function robotstxt_docmd_update_mapping( int $mapping_id, array $data ): bool { // Update post title. wp_update_post( array( 'ID' => $mapping_id, 'post_title' => $data['title'], ) ); // Update metadata. update_post_meta( $mapping_id, '_robotstxt_docmd_repo_owner', $data['repo_owner'] ); update_post_meta( $mapping_id, '_robotstxt_docmd_repo_name', $data['repo_name'] ); update_post_meta( $mapping_id, '_robotstxt_docmd_file_path', $data['file_path'] ); update_post_meta( $mapping_id, '_robotstxt_docmd_branch', $data['branch'] ); update_post_meta( $mapping_id, '_robotstxt_docmd_target_post_type', $data['target_post_type'] ); update_post_meta( $mapping_id, '_robotstxt_docmd_target_author', $data['target_author'] ); update_post_meta( $mapping_id, '_robotstxt_docmd_target_parent', $data['target_parent'] ); update_post_meta( $mapping_id, '_robotstxt_docmd_target_order', $data['target_order'] ); update_post_meta( $mapping_id, '_robotstxt_docmd_sync_enabled', $data['sync_enabled'] ? 1 : 0 ); update_post_meta( $mapping_id, '_robotstxt_docmd_sync_frequency', $data['sync_frequency'] ); if ( ! empty( $data['target_post_id'] ) ) { update_post_meta( $mapping_id, '_robotstxt_docmd_target_post_id', $data['target_post_id'] ); } // Update cron schedule. robotstxt_docmd_unschedule_mapping_sync( $mapping_id ); if ( $data['sync_enabled'] && 'never' !== $data['sync_frequency'] ) { robotstxt_docmd_schedule_mapping_sync( $mapping_id, $data['sync_frequency'] ); } return true; } /** * Delete mapping * * @since 1.0.0 * * @param int $mapping_id Mapping ID. * @return bool True on success. */ function robotstxt_docmd_delete_mapping( int $mapping_id ): bool { robotstxt_docmd_unschedule_mapping_sync( $mapping_id ); wp_delete_post( $mapping_id, true ); return true; } /** * Check if file is already mapped * * @since 1.0.0 * * @param string $owner Repository owner. * @param string $repo Repository name. * @param string $file_path File path. * @param string $branch Branch name. * @return bool True if mapped. */ function robotstxt_docmd_is_file_mapped( string $owner, string $repo, string $file_path, string $branch ): bool { $args = array( 'post_type' => 'robotstxt_map', 'posts_per_page' => 1, 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query array( 'key' => '_robotstxt_docmd_repo_owner', 'value' => $owner, ), array( 'key' => '_robotstxt_docmd_repo_name', 'value' => $repo, ), array( 'key' => '_robotstxt_docmd_file_path', 'value' => $file_path, ), array( 'key' => '_robotstxt_docmd_branch', 'value' => $branch, ), ), ); $query = new WP_Query( $args ); return $query->have_posts(); } /** * Sync mapping (pull content from GitHub and update WordPress) * * @since 1.0.0 * * @param int $mapping_id Mapping ID. * @return bool|WP_Error True on success or error. */ function robotstxt_docmd_sync_mapping( int $mapping_id ): bool|WP_Error { $mapping = robotstxt_docmd_get_mapping( $mapping_id ); if ( ! $mapping ) { return new WP_Error( 'invalid_mapping', __( 'Mapping not found', 'robotstxt-documentation-markdown' ) ); } // Update status. update_post_meta( $mapping_id, '_robotstxt_docmd_sync_status', 'pending' ); // Get settings. $raw_settings = get_option( 'robotstxt_docmd_settings' ); $settings = is_array( $raw_settings ) ? $raw_settings : array(); $token_raw = $settings['github_token'] ?? ''; $token = is_string( $token_raw ) ? $token_raw : ''; if ( empty( $token ) ) { update_post_meta( $mapping_id, '_robotstxt_docmd_sync_status', 'error' ); return new WP_Error( 'no_token', __( 'GitHub token not configured', 'robotstxt-documentation-markdown' ) ); } // Get file content from GitHub. $content = robotstxt_docmd_get_file_content( $mapping['repo_owner'], $mapping['repo_name'], $mapping['file_path'], $mapping['branch'], $token ); if ( is_wp_error( $content ) ) { update_post_meta( $mapping_id, '_robotstxt_docmd_sync_status', 'error' ); return $content; } // Derive the post title from the first H1 and remove that H1 from the body. $extracted = robotstxt_docmd_extract_h1_title( $content, $mapping['title'] ); $content = $extracted['content']; $post_title = $extracted['title']; // Convert Markdown to HTML using CommonMark. // Strip raw HTML from Markdown to prevent stored XSS via compromised upstream repos. $converter = new \League\CommonMark\CommonMarkConverter( array( 'html_input' => 'strip', 'allow_unsafe_links' => false, ) ); $html_content = $converter->convert( $content )->getContent(); // Create or update WordPress post. if ( empty( $mapping['target_post_id'] ) ) { // Create new post. $post_id = wp_insert_post( array( 'post_title' => $post_title, 'post_content' => $html_content, 'post_type' => $mapping['target_post_type'], 'post_status' => 'publish', 'post_author' => $mapping['target_author'], 'post_parent' => $mapping['target_parent'], 'menu_order' => $mapping['target_order'], ), true ); if ( is_wp_error( $post_id ) ) { update_post_meta( $mapping_id, '_robotstxt_docmd_sync_status', 'error' ); return $post_id; } // Save the post ID for future syncs. update_post_meta( $mapping_id, '_robotstxt_docmd_target_post_id', $post_id ); $target_post_id = (int) $post_id; } else { $target_post_id = (int) $mapping['target_post_id']; } // Post-process: translate internal links and sideload repository images. $asset_map = robotstxt_docmd_get_asset_map( $target_post_id ); $processed = robotstxt_docmd_process_synced_html( $html_content, $mapping, $target_post_id, $token, $asset_map ); if ( '' !== $processed['html'] ) { $html_content = $processed['html']; } robotstxt_docmd_save_asset_map( $target_post_id, $processed['assets'] ); // Persist the rewritten content and the H1-derived title. // NOTE: post_name (slug) is intentionally NEVER passed here, so the slug // remains stable across syncs even though the title follows the H1. $update_result = wp_update_post( array( 'ID' => $target_post_id, 'post_title' => $post_title, 'post_content' => $html_content, ), true ); if ( is_wp_error( $update_result ) ) { update_post_meta( $mapping_id, '_robotstxt_docmd_sync_status', 'error' ); return $update_result; } // Update sync status. update_post_meta( $mapping_id, '_robotstxt_docmd_sync_status', 'success' ); update_post_meta( $mapping_id, '_robotstxt_docmd_last_sync', current_time( 'mysql' ) ); return true; }