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 +89 -31 4.4.04.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.'
@@ -207,30 +216,34 @@
207 216
208 217 // Initialize the combined array with headers
209 218 $csv_data_combined = [ $headers_combined ];
210 219
211 - // Combine posts data
212 - for ( $i = 1; $i < count( $csv_data_posts ); $i++ ) {
220 + $author_start_index = count( $csv_data_posts[0] );
221 + $terms_start_index = $author_start_index + count( $csv_data_author[0] ) - 1;
222 +
223 + // Author rows
224 + for ( $i = 1; $i < count( $csv_data_author ); $i++ ) {
213 225 $combined_row = array_merge(
214 - $csv_data_posts[ $i ],
215 - array_fill( 0, count( $headers_combined ) - count( $csv_data_posts[ $i ] ), '' )
226 + [ $csv_data_author[ $i ][0] ],
227 + array_fill( 1, $author_start_index - 1, '' ),
228 + array_slice( $csv_data_author[ $i ], 1 )
216 229 );
217 230 $csv_data_combined[] = $combined_row;
218 231 }
219 232
220 - // Combine author data
221 - $author_start_index = count( $csv_data_posts[0] );
222 - for ( $i = 1; $i < count( $csv_data_author ); $i++ ) {
233 + // Docs post rows
234 + for ( $i = 1; $i < count( $csv_data_posts ); $i++ ) {
235 + if ( $csv_data_posts[ $i ][0] === 'FAQ' ) {
236 + continue;
237 + }
223 238 $combined_row = array_merge(
224 - [ $csv_data_author[ $i ][0] ],
225 - array_fill( 1, $author_start_index - 1, '' ),
226 - array_slice( $csv_data_author[ $i ], 1 )
239 + $csv_data_posts[ $i ],
240 + array_fill( 0, count( $headers_combined ) - count( $csv_data_posts[ $i ] ), '' )
227 241 );
228 242 $csv_data_combined[] = $combined_row;
229 243 }
230 244
231 - // Combine terms data
232 - $terms_start_index = $author_start_index + count( $csv_data_author[0] ) - 1;
245 + // Term rows just before FAQ rows so category IDs can be resolved on import
233 246 for ( $i = 1; $i < count( $csv_data_terms ); $i++ ) {
234 247 $combined_row = array_merge(
235 248 [ $csv_data_terms[ $i ][0] ],
236 249 array_fill( 1, $terms_start_index - 1, '' ),
@@ -238,9 +251,21 @@
238 251 );
239 252 $csv_data_combined[] = $combined_row;
240 253 }
241 254
242 - $filename = 'betterdocs.' . date( 'Y-m-d' ) . '.csv';
255 + // FAQ post rows last
256 + for ( $i = 1; $i < count( $csv_data_posts ); $i++ ) {
257 + if ( $csv_data_posts[ $i ][0] !== 'FAQ' ) {
258 + continue;
259 + }
260 + $combined_row = array_merge(
261 + $csv_data_posts[ $i ],
262 + array_fill( 0, count( $headers_combined ) - count( $csv_data_posts[ $i ] ), '' )
263 + );
264 + $csv_data_combined[] = $combined_row;
265 + }
266 +
267 + $filename = 'betterdocs.' . gmdate( 'Y-m-d' ) . '.csv';
243 268 $csv_content = $this->generate_csv( $csv_data_combined );
244 269
245 270 return [
246 271 'success' => true,
@@ -424,12 +449,20 @@
424 449 array_push( $glossary_term_ids, $term_object->term_id );
425 450 }
426 451 }
427 452 } else {
428 - $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
429 462 }
430 463
431 - $filename = 'betterdocs.' . date( 'Y-m-d' ) . '.csv';
464 + $filename = 'betterdocs.' . gmdate( 'Y-m-d' ) . '.csv';
432 465 $csv_data_combined = $this->get_glossaries_csv_data( $glossary_term_ids );
433 466 $csv_content = $this->generate_csv( $csv_data_combined );
434 467
435 468 return [
@@ -560,14 +593,17 @@
560 593 'Doc Categories',
561 594 'Doc Tags',
562 595 'Knowledge Bases',
563 596 'Docs attachement url',
564 - 'Docs attachement ID'
597 + 'Docs attachement ID',
598 + 'Docs language code',
599 + 'Docs translation source slug',
565 600 ];
566 601
567 602 foreach ( $posts as $post ) {
568 603 $attachment_id = get_post_thumbnail_id( $post->ID );
569 604 $attachment_url = get_the_post_thumbnail_url( $post->ID );
605 + $wpml = WPMLSupport::get_post_language_meta( (int) $post->ID );
570 606 // Add CSV row for post
571 607 $csv_data_posts[] = [
572 608 $post->post_type == 'betterdocs_faq' ? 'FAQ' : 'Docs',
573 609 $post->ID,
@@ -585,13 +621,15 @@
585 621 $post->post_parent,
586 622 $post->menu_order,
587 623 $post->post_mime_type,
588 624 $post->comment_count,
589 - $this->get_term_ids( $post->ID, 'doc_category' ),
625 + $this->get_term_ids( $post->ID, $post->post_type === 'betterdocs_faq' ? [ 'betterdocs_faq_category', 'betterdocs_product_faq_category' ] : 'doc_category' ),
590 626 $this->get_term_ids( $post->ID, 'doc_tag' ),
591 627 $this->get_term_ids( $post->ID, 'knowledge_base' ),
592 628 $attachment_url ? $attachment_url : '',
593 - $attachment_id ? $attachment_id : ''
629 + $attachment_id ? $attachment_id : '',
630 + $wpml ? $wpml['language_code'] : '',
631 + $wpml ? $wpml['source_slug'] : '',
594 632 ];
595 633 }
596 634
597 635 return $csv_data_posts;
@@ -597,13 +635,15 @@
597 635 return $csv_data_posts;
598 636 }
599 637
600 638 public function get_term_ids( $post_id, $taxonomy ) {
601 - $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' ] );
602 643
603 - if ( $terms && ! is_wp_error( $terms ) ) {
604 - $term_ids = wp_list_pluck( $terms, 'term_id' );
605 - return implode( ', ', $term_ids );
644 + if ( $term_ids && ! is_wp_error( $term_ids ) ) {
645 + return implode( ', ', array_map( 'intval', $term_ids ) );
606 646 }
607 647
608 648 return '';
609 649 }
@@ -612,14 +652,32 @@
612 652 ob_start();
613 653
614 654 $output = fopen( 'php://output', 'w' );
615 655
616 - // 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.
617 660 foreach ( $data as $row ) {
618 - fputcsv( $output, $row );
661 + fputcsv( $output, array_map( [ $this, 'neutralize_csv_cell' ], (array) $row ) );
619 662 }
620 663
664 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing php://output stream; WP_Filesystem does not apply.
621 665 fclose( $output );
622 666
623 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;
624 682 }
625 683 }