PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/Admin/CSVExporter.php +61 -19 4.5.24.9.2 View file →
@@ -17,8 +17,9 @@
17 17 'end_date' => false,
18 18 'status' => false,
19 19 'offset' => 0,
20 20 'limit' => -1,
21 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- exporter accepts user-defined meta filters by design.
21 22 'meta_query' => [], // If specified `meta_key` then will include all post(s) that have this meta_key.
22 23 'query_args' => []
23 24 ];
24 25
@@ -73,8 +74,11 @@
73 74 if ( ! in_array( $this->args['content'], $allowed_post_types ) ) {
74 75 return [];
75 76 }
76 77
78 + // $this->wpdb->posts and $this->wpdb->term_relationships are WP-provided table identifiers.
79 + // Dynamic %d placeholder lists are built to match the corresponding integer arrays.
80 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
77 81 $where = $this->wpdb->prepare(
78 82 "{$this->wpdb->posts}.post_type = %s",
79 83 $this->args['content']
80 84 );
@@ -88,9 +92,9 @@
88 92 $where .= " AND {$this->wpdb->posts}.post_status != 'auto-draft'";
89 93 }
90 94
91 95 if ( ! empty( $this->args['post__in'] ) ) {
92 - $post_in = $this->args['post__in'];
96 + $post_in = array_map( 'intval', $this->args['post__in'] );
93 97 $ids_placeholder = implode( ', ', array_fill( 0, count( $post_in ), '%d' ) );
94 98 $where .= $this->wpdb->prepare(
95 99 " AND {$this->wpdb->posts}.ID IN ($ids_placeholder)",
96 100 $post_in
@@ -106,9 +110,9 @@
106 110 // Handle doc categories
107 111 foreach ( $this->args['category_terms'] as $term_slug ) {
108 112 $term = get_term_by( 'slug', $term_slug, 'doc_category' );
109 113 if ( $term ) {
110 - $tax_terms[] = $term->term_taxonomy_id;
114 + $tax_terms[] = (int) $term->term_taxonomy_id;
111 115 }
112 116 }
113 117
114 118 if ( ! empty( $tax_terms ) ) {
@@ -120,16 +124,16 @@
120 124 }
121 125 } elseif ( isset( $this->args['kb_terms'] ) ) {
122 126 $join = "INNER JOIN {$this->wpdb->term_relationships} ON ({$this->wpdb->posts}.ID = {$this->wpdb->term_relationships}.object_id)";
123 127 $kb_terms = [];
124 -
128 +
125 129 foreach ( $this->args['kb_terms'] as $term_slug ) {
126 130 $term = get_term_by( 'slug', $term_slug, 'knowledge_base' );
127 131 if ( $term ) {
128 - $kb_terms[] = $term->term_taxonomy_id;
132 + $kb_terms[] = (int) $term->term_taxonomy_id;
129 133 }
130 134 }
131 -
135 +
132 136 if ( ! empty( $kb_terms ) ) {
133 137 $term_placeholder = implode( ', ', array_fill( 0, count( $kb_terms ), '%d' ) );
134 138 $where .= $this->wpdb->prepare(
135 139 " AND {$this->wpdb->term_relationships}.term_taxonomy_id IN ($term_placeholder)",
@@ -166,9 +170,10 @@
166 170 $join .= ' ' . $query_clauses['join'];
167 171 $where .= ' ' . $query_clauses['where'];
168 172 }
169 173
170 - // Get post IDs
174 + // $where and $join are composed from prepared fragments above; identifiers are WP-provided.
175 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
171 176 $post_ids = $this->wpdb->get_col( "SELECT ID FROM {$this->wpdb->posts} $join WHERE $where" );
172 177
173 178 // Add FAQ post IDs if include_faq is true
174 179 if ( ! empty( $this->args['include_faq'] ) ) {
@@ -176,14 +181,18 @@
176 181 [
177 182 'post_type' => 'betterdocs_faq',
178 183 'posts_per_page' => -1,
179 184 'fields' => 'ids',
180 - 'post_status' => 'publish'
185 + 'post_status' => 'publish',
186 + // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- intentional: export the raw, untranslated FAQ set so multilingual filters don't drop or swap rows during export.
187 + 'suppress_filters' => true,
181 188 ]
182 189 );
183 190 $post_ids = array_merge( $post_ids, $faq_ids );
184 191 }
185 192
193 + $post_ids = WPMLSupport::expand_with_translations( $post_ids );
194 +
186 195 if ( empty( $post_ids ) ) {
187 196 return [
188 197 'success' => false,
189 198 'message' => 'No posts found matching the criteria.'
@@ -254,9 +263,9 @@
254 263 );
255 264 $csv_data_combined[] = $combined_row;
256 265 }
257 266
258 - $filename = 'betterdocs.' . date( 'Y-m-d' ) . '.csv';
267 + $filename = 'betterdocs.' . gmdate( 'Y-m-d' ) . '.csv';
259 268 $csv_content = $this->generate_csv( $csv_data_combined );
260 269
261 270 return [
262 271 'success' => true,
@@ -440,12 +449,20 @@
440 449 array_push( $glossary_term_ids, $term_object->term_id );
441 450 }
442 451 }
443 452 } else {
444 - $glossary_term_ids = $this->wpdb->get_col( "SELECT term_id from {$this->wpdb->term_taxonomy} where taxonomy='{$this->args['content']}';" );
453 + // $this->wpdb->term_taxonomy is a WP-core table identifier; %s placeholder binds taxonomy name.
454 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
455 + $glossary_term_ids = $this->wpdb->get_col(
456 + $this->wpdb->prepare(
457 + "SELECT term_id FROM {$this->wpdb->term_taxonomy} WHERE taxonomy = %s",
458 + (string) $this->args['content']
459 + )
460 + );
461 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
445 462 }
446 463
447 - $filename = 'betterdocs.' . date( 'Y-m-d' ) . '.csv';
464 + $filename = 'betterdocs.' . gmdate( 'Y-m-d' ) . '.csv';
448 465 $csv_data_combined = $this->get_glossaries_csv_data( $glossary_term_ids );
449 466 $csv_content = $this->generate_csv( $csv_data_combined );
450 467
451 468 return [
@@ -576,14 +593,17 @@
576 593 'Doc Categories',
577 594 'Doc Tags',
578 595 'Knowledge Bases',
579 596 'Docs attachement url',
580 - 'Docs attachement ID'
597 + 'Docs attachement ID',
598 + 'Docs language code',
599 + 'Docs translation source slug',
581 600 ];
582 601
583 602 foreach ( $posts as $post ) {
584 603 $attachment_id = get_post_thumbnail_id( $post->ID );
585 604 $attachment_url = get_the_post_thumbnail_url( $post->ID );
605 + $wpml = WPMLSupport::get_post_language_meta( (int) $post->ID );
586 606 // Add CSV row for post
587 607 $csv_data_posts[] = [
588 608 $post->post_type == 'betterdocs_faq' ? 'FAQ' : 'Docs',
589 609 $post->ID,
@@ -601,13 +621,15 @@
601 621 $post->post_parent,
602 622 $post->menu_order,
603 623 $post->post_mime_type,
604 624 $post->comment_count,
605 - $this->get_term_ids( $post->ID, $post->post_type === 'betterdocs_faq' ? 'betterdocs_faq_category' : 'doc_category' ),
625 + $this->get_term_ids( $post->ID, $post->post_type === 'betterdocs_faq' ? [ 'betterdocs_faq_category', 'betterdocs_product_faq_category' ] : 'doc_category' ),
606 626 $this->get_term_ids( $post->ID, 'doc_tag' ),
607 627 $this->get_term_ids( $post->ID, 'knowledge_base' ),
608 628 $attachment_url ? $attachment_url : '',
609 - $attachment_id ? $attachment_id : ''
629 + $attachment_id ? $attachment_id : '',
630 + $wpml ? $wpml['language_code'] : '',
631 + $wpml ? $wpml['source_slug'] : '',
610 632 ];
611 633 }
612 634
613 635 return $csv_data_posts;
@@ -613,13 +635,15 @@
613 635 return $csv_data_posts;
614 636 }
615 637
616 638 public function get_term_ids( $post_id, $taxonomy ) {
617 - $terms = get_the_terms( $post_id, $taxonomy );
639 + // Accept one or more taxonomies. FAQ posts can live in either the general
640 + // (betterdocs_faq_category) or the Product FAQ (betterdocs_product_faq_category)
641 + // taxonomy, so both are queried for the FAQ group column.
642 + $term_ids = wp_get_object_terms( $post_id, (array) $taxonomy, [ 'fields' => 'ids' ] );
618 643
619 - if ( $terms && ! is_wp_error( $terms ) ) {
620 - $term_ids = wp_list_pluck( $terms, 'term_id' );
621 - return implode( ', ', $term_ids );
644 + if ( $term_ids && ! is_wp_error( $term_ids ) ) {
645 + return implode( ', ', array_map( 'intval', $term_ids ) );
622 646 }
623 647
624 648 return '';
625 649 }
@@ -628,14 +652,32 @@
628 652 ob_start();
629 653
630 654 $output = fopen( 'php://output', 'w' );
631 655
632 - // Add CSV rows
656 + // Add CSV rows. Neutralize spreadsheet formula injection: a cell that a
657 + // lower-privileged author controls (e.g. a doc/FAQ title or term name) could
658 + // start with =, +, -, @, or a tab/CR and execute when the admin opens the
659 + // export in Excel/LibreOffice. Prefix such cells with a single quote.
633 660 foreach ( $data as $row ) {
634 - fputcsv( $output, $row );
661 + fputcsv( $output, array_map( [ $this, 'neutralize_csv_cell' ], (array) $row ) );
635 662 }
636 663
664 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing php://output stream; WP_Filesystem does not apply.
637 665 fclose( $output );
638 666
639 667 return ob_get_clean();
668 + }
669 +
670 + /**
671 + * Prefix a leading formula trigger (= + - @ tab CR) with a single quote so
672 + * spreadsheet apps treat the cell as text instead of executing it.
673 + */
674 + private function neutralize_csv_cell( $cell ) {
675 + $cell = (string) $cell;
676 +
677 + if ( $cell !== '' && preg_match( '/^[=+\-@\t\r]/', $cell ) ) {
678 + return "'" . $cell;
679 + }
680 +
681 + return $cell;
640 682 }
641 683 }