PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.8.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.8.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 3.5.2 All 199 releases
betterdocs / includes / Admin / CSVExporter.php

CSVExporter.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.8.2, at includes/Admin/CSVExporter.php

684 lines 20.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace WPDeveloper\BetterDocs\Admin;
3
4 use Error;
5
6 if ( ! defined( 'ABSPATH' ) ) {
7 exit; // Exit if accessed directly.
8 }
9
10 #[\AllowDynamicProperties]
11 class CSVExporter {
12 private static $default_args = [
13 'content' => 'docs',
14 'author' => false,
15 'category' => false,
16 'start_date' => false,
17 'end_date' => false,
18 'status' => false,
19 'offset' => 0,
20 'limit' => -1,
21 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- exporter accepts user-defined meta filters by design.
22 'meta_query' => [], // If specified `meta_key` then will include all post(s) that have this meta_key.
23 'query_args' => []
24 ];
25
26 /**
27 * @var array
28 */
29 private $args;
30
31 /**
32 * @var wpdb
33 */
34 private $wpdb;
35
36 public function __construct( array $args = [] ) {
37 global $wpdb;
38
39 $this->args = wp_parse_args( $args, self::$default_args );
40
41 $this->wpdb = $wpdb;
42 }
43
44 public function combine_csv_data( $csv_data_array ) {
45 // Combine headers
46 $headers_combined = $csv_data_array[0][0];
47
48 foreach ( $csv_data_array as $csv_data ) {
49 $headers_combined = array_merge( $headers_combined, array_slice( $csv_data[0], 1 ) );
50 }
51
52 $csv_data_combined = [ $headers_combined ];
53
54 // Combine data
55 for ( $i = 1; $i < count( $csv_data_array[0] ); $i++ ) {
56 $combined_row = [];
57 foreach ( $csv_data_array as $csv_data ) {
58 $combined_row = array_merge( $combined_row, array_fill( 0, count( $csv_data_combined[0] ) - count( $combined_row ) ), [ $csv_data[ $i ][0] ], array_slice( $csv_data[ $i ], 1 ) );
59 }
60 $csv_data_combined[] = $combined_row;
61 }
62
63 return $csv_data_combined;
64 }
65
66
67 public function run(): array {
68 $allowed_post_types = [ 'docs', 'betterdocs_faq' ];
69
70 if ( $this->args['content'] === 'glossaries' ) {
71 return $this->handle_glossaries_export();
72 }
73
74 if ( ! in_array( $this->args['content'], $allowed_post_types ) ) {
75 return [];
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
81 $where = $this->wpdb->prepare(
82 "{$this->wpdb->posts}.post_type = %s",
83 $this->args['content']
84 );
85
86 if ( $this->args['status'] ) {
87 $where .= $this->wpdb->prepare(
88 " AND {$this->wpdb->posts}.post_status = %s",
89 $this->args['status']
90 );
91 } else {
92 $where .= " AND {$this->wpdb->posts}.post_status != 'auto-draft'";
93 }
94
95 if ( ! empty( $this->args['post__in'] ) ) {
96 $post_in = array_map( 'intval', $this->args['post__in'] );
97 $ids_placeholder = implode( ', ', array_fill( 0, count( $post_in ), '%d' ) );
98 $where .= $this->wpdb->prepare(
99 " AND {$this->wpdb->posts}.ID IN ($ids_placeholder)",
100 $post_in
101 );
102 }
103
104 $join = '';
105
106 if ( isset( $this->args['category_terms'] ) ) {
107 $join = "INNER JOIN {$this->wpdb->term_relationships} ON ({$this->wpdb->posts}.ID = {$this->wpdb->term_relationships}.object_id)";
108 $tax_terms = [];
109
110 // Handle doc categories
111 foreach ( $this->args['category_terms'] as $term_slug ) {
112 $term = get_term_by( 'slug', $term_slug, 'doc_category' );
113 if ( $term ) {
114 $tax_terms[] = (int) $term->term_taxonomy_id;
115 }
116 }
117
118 if ( ! empty( $tax_terms ) ) {
119 $tax_placeholder = implode( ', ', array_fill( 0, count( $tax_terms ), '%d' ) );
120 $where .= $this->wpdb->prepare(
121 " AND {$this->wpdb->term_relationships}.term_taxonomy_id IN ($tax_placeholder)",
122 $tax_terms
123 );
124 }
125 } elseif ( isset( $this->args['kb_terms'] ) ) {
126 $join = "INNER JOIN {$this->wpdb->term_relationships} ON ({$this->wpdb->posts}.ID = {$this->wpdb->term_relationships}.object_id)";
127 $kb_terms = [];
128
129 foreach ( $this->args['kb_terms'] as $term_slug ) {
130 $term = get_term_by( 'slug', $term_slug, 'knowledge_base' );
131 if ( $term ) {
132 $kb_terms[] = (int) $term->term_taxonomy_id;
133 }
134 }
135
136 if ( ! empty( $kb_terms ) ) {
137 $term_placeholder = implode( ', ', array_fill( 0, count( $kb_terms ), '%d' ) );
138 $where .= $this->wpdb->prepare(
139 " AND {$this->wpdb->term_relationships}.term_taxonomy_id IN ($term_placeholder)",
140 $kb_terms
141 );
142 }
143 }
144
145 if ( $this->args['author'] ) {
146 $where .= $this->wpdb->prepare(
147 " AND {$this->wpdb->posts}.post_author = %d",
148 $this->args['author']
149 );
150 }
151
152 if ( $this->args['start_date'] ) {
153 $where .= $this->wpdb->prepare(
154 " AND {$this->wpdb->posts}.post_date >= %s",
155 gmdate( 'Y-m-d', strtotime( $this->args['start_date'] ) )
156 );
157 }
158
159 if ( $this->args['end_date'] ) {
160 $where .= $this->wpdb->prepare(
161 " AND {$this->wpdb->posts}.post_date < %s",
162 gmdate( 'Y-m-d', strtotime( '+1 month', strtotime( $this->args['end_date'] ) ) )
163 );
164 }
165
166 if ( ! empty( $this->args['meta_query'] ) ) {
167 $meta_query = new \WP_Meta_Query( $this->args['meta_query'] );
168 $query_clauses = $meta_query->get_sql( 'post', $this->wpdb->posts, 'ID' );
169
170 $join .= ' ' . $query_clauses['join'];
171 $where .= ' ' . $query_clauses['where'];
172 }
173
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
176 $post_ids = $this->wpdb->get_col( "SELECT ID FROM {$this->wpdb->posts} $join WHERE $where" );
177
178 // Add FAQ post IDs if include_faq is true
179 if ( ! empty( $this->args['include_faq'] ) ) {
180 $faq_ids = get_posts(
181 [
182 'post_type' => 'betterdocs_faq',
183 'posts_per_page' => -1,
184 'fields' => 'ids',
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,
188 ]
189 );
190 $post_ids = array_merge( $post_ids, $faq_ids );
191 }
192
193 $post_ids = WPMLSupport::expand_with_translations( $post_ids );
194
195 if ( empty( $post_ids ) ) {
196 return [
197 'success' => false,
198 'message' => 'No posts found matching the criteria.'
199 ];
200 }
201
202 // Get posts data
203 $posts = array_map( 'get_post', $post_ids );
204
205 // Prepare CSV data
206 $csv_data_terms = $this->get_terms_csv_data( $post_ids );
207 $csv_data_author = $this->authors_list( $post_ids );
208 $csv_data_posts = $this->get_posts_csv_data( $posts );
209
210 // Combine headers
211 $headers_combined = array_merge(
212 $csv_data_posts[0],
213 array_slice( $csv_data_author[0], 1 ),
214 array_slice( $csv_data_terms[0], 1 )
215 );
216
217 // Initialize the combined array with headers
218 $csv_data_combined = [ $headers_combined ];
219
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++ ) {
225 $combined_row = array_merge(
226 [ $csv_data_author[ $i ][0] ],
227 array_fill( 1, $author_start_index - 1, '' ),
228 array_slice( $csv_data_author[ $i ], 1 )
229 );
230 $csv_data_combined[] = $combined_row;
231 }
232
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 }
238 $combined_row = array_merge(
239 $csv_data_posts[ $i ],
240 array_fill( 0, count( $headers_combined ) - count( $csv_data_posts[ $i ] ), '' )
241 );
242 $csv_data_combined[] = $combined_row;
243 }
244
245 // Term rows just before FAQ rows so category IDs can be resolved on import
246 for ( $i = 1; $i < count( $csv_data_terms ); $i++ ) {
247 $combined_row = array_merge(
248 [ $csv_data_terms[ $i ][0] ],
249 array_fill( 1, $terms_start_index - 1, '' ),
250 array_slice( $csv_data_terms[ $i ], 1 )
251 );
252 $csv_data_combined[] = $combined_row;
253 }
254
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';
268 $csv_content = $this->generate_csv( $csv_data_combined );
269
270 return [
271 'success' => true,
272 'data' => [
273 'filename' => $filename,
274 'filetype' => 'text/csv',
275 'download' => $csv_content,
276 ]
277 ];
278 }
279
280 /**
281 * Retrieve terms associated with the specified object IDs and sort them based on term meta.
282 *
283 * @param array $post_ids An array of object IDs.
284 * @return array An array of WP_Term objects sorted based on term meta.
285 */
286 private function get_terms( array $post_ids, $include_faq = false ) {
287 $post_types = [
288 'docs'
289 ];
290
291 if ( $include_faq ) {
292 array_push( $post_types, 'betterdocs_faq' );
293 }
294
295 // Get the object taxonomies
296 $taxonomies = get_object_taxonomies( $post_types );
297
298 // Get the object terms with parent terms coming before their child terms
299 $terms = wp_get_object_terms( $post_ids, $taxonomies );
300
301 usort( $terms, array( $this, 'compare_terms_by_meta' ) );
302
303 return $terms;
304 }
305
306 /**
307 * Compare terms based on their associated term meta values.
308 *
309 * @param WP_Term $a The first term object.
310 * @param WP_Term $b The second term object.
311 * @return int Returns a negative value if $a is less than $b,
312 * a positive value if $a is greater than $b, or 0 if they are equal.
313 * Additionally, prioritize sorting terms by taxonomy order,
314 * with 'doc_category' terms appearing before other taxonomy terms.
315 */
316 public function compare_terms_by_meta( $a, $b ) {
317 // Define the order of taxonomies
318 $taxonomy_order = array(
319 'doc_category' => 0,
320 'knowledge_base' => 1,
321 'doc_tag' => 2,
322 );
323
324 // Get the taxonomy order for terms $a and $b
325 $order_a = isset( $taxonomy_order[ $a->taxonomy ] ) ? $taxonomy_order[ $a->taxonomy ] : PHP_INT_MAX;
326 $order_b = isset( $taxonomy_order[ $b->taxonomy ] ) ? $taxonomy_order[ $b->taxonomy ] : PHP_INT_MAX;
327
328 // If the taxonomies have different order, sort by order
329 if ( $order_a !== $order_b ) {
330 return $order_a - $order_b;
331 }
332
333 // If the taxonomies have the same order, sort by meta value
334 $taxonomy_order_meta = array(
335 'doc_category' => 'doc_category_order',
336 'knowledge_base' => 'kb_order'
337 );
338
339 if ( isset( $taxonomy_order_meta[ $a->taxonomy ] ) && isset( $taxonomy_order_meta[ $b->taxonomy ] ) ) {
340 $meta_a = intval( get_term_meta( $a->term_id, $taxonomy_order_meta[ $a->taxonomy ], true ) );
341 $meta_b = intval( get_term_meta( $b->term_id, $taxonomy_order_meta[ $b->taxonomy ], true ) );
342
343 return $meta_a - $meta_b;
344 }
345
346 return 0; // Default to no sorting if meta keys are not defined
347 }
348
349 /**
350 * Sorting function for sorting term data.
351 *
352 * @param array $a The first array to compare.
353 * @param array $b The second array to compare.
354 *
355 * @return int Returns an integer less than, equal to, or greater than zero if the first array is considered
356 * to be respectively less than, equal to, or greater than the second.
357 */
358 public function sort_terms( $a, $b ) {
359 // Compare parent values
360 $parentComparison = strcmp( $a[7], $b[7] );
361
362 // If parent values are equal, compare Term ID values
363 return ( $parentComparison === 0 ) ? strcmp( $a[2], $b[2] ) : $parentComparison;
364 }
365
366 private function get_terms_csv_data( $post_ids ) {
367 $csv_data_terms = [];
368
369 // Add CSV headers for terms
370 $csv_data_terms[] = [
371 'Type',
372 'Taxonomy',
373 'Term ID',
374 'Term name',
375 'Term slug',
376 'Term group',
377 'Term description',
378 'Term parent',
379 'Assigned Docs',
380 'Assigned KBs',
381 'Doc Category order',
382 'KB order', // Add additional term meta headers here
383 ];
384
385 if ( $this->args['content'] == 'glossaries' ) {
386 $terms = get_terms(
387 [
388 'taxonomy' => 'glossaries',
389 'hide_empty' => false,
390 ]
391 );
392 } else {
393 $terms = $this->get_terms( $post_ids, $this->args['include_faq'] );
394 }
395 foreach ( $terms as $term ) {
396 $term_meta = '';
397
398 // Add term meta based on taxonomy
399 switch ( $term->taxonomy ) {
400 case 'doc_category':
401 $doc_category_knowledge_base = maybe_unserialize( get_term_meta( $term->term_id, 'doc_category_knowledge_base', true ) );
402 if ( is_array( $doc_category_knowledge_base ) && $doc_category_knowledge_base !== false ) {
403 $doc_category_knowledge_base = implode( ', ', array_filter( $doc_category_knowledge_base ) );
404 } else {
405 $doc_category_knowledge_base = '';
406 }
407
408 $term_meta = [
409 '_docs_order' => get_term_meta( $term->term_id, '_docs_order', true ),
410 'doc_category_knowledge_base' => $doc_category_knowledge_base,
411 'doc_category_order' => get_term_meta( $term->term_id, 'doc_category_order', true ),
412 ];
413 break;
414
415 case 'knowledge_base':
416 $term_meta = [
417 'kb_order' => get_term_meta( $term->term_id, 'kb_order', true ),
418 ];
419 break;
420 }
421
422 $parent = $term->parent ? get_term_by( 'id', $term->parent, $term->taxonomy ) : '';
423 // Add CSV row for term
424 $csv_data_terms[] = [
425 'Term',
426 $term->taxonomy,
427 $term->term_id,
428 $term->name,
429 $term->slug,
430 $term->term_group,
431 $term->description,
432 $parent ? $parent->slug : '',
433 isset( $term_meta['_docs_order'] ) ? $term_meta['_docs_order'] : '',
434 isset( $term_meta['doc_category_knowledge_base'] ) ? $term_meta['doc_category_knowledge_base'] : '',
435 isset( $term_meta['doc_category_order'] ) ? $term_meta['doc_category_order'] : '',
436 isset( $term_meta['kb_order'] ) ? $term_meta['kb_order'] : '',
437 ];
438 }
439
440 return $csv_data_terms;
441 }
442
443 private function handle_glossaries_export(): array {
444 if ( isset( $this->args['glossary_terms'] ) && ( count( $this->args['glossary_terms'] ) > 0 ) ) {
445 $glossary_term_ids = [];
446 foreach ( $this->args['glossary_terms'] as $glossary_slug ) {
447 $term_object = get_term_by( 'slug', $glossary_slug, 'glossaries' );
448 if ( isset( $term_object->term_id ) && ! empty( $term_object->term_id ) ) {
449 array_push( $glossary_term_ids, $term_object->term_id );
450 }
451 }
452 } else {
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
462 }
463
464 $filename = 'betterdocs.' . gmdate( 'Y-m-d' ) . '.csv';
465 $csv_data_combined = $this->get_glossaries_csv_data( $glossary_term_ids );
466 $csv_content = $this->generate_csv( $csv_data_combined );
467
468 return [
469 'success' => true,
470 'data' => [
471 'filename' => $filename,
472 'filetype' => 'text/csv',
473 'download' => $csv_content,
474 ],
475 ];
476 }
477
478 private function get_glossaries_csv_data( $post_ids ) {
479 $csv_data_terms = [];
480
481 if ( $this->args['content'] == 'glossaries' ) {
482 $terms = get_terms(
483 [
484 'taxonomy' => 'glossaries',
485 'hide_empty' => false,
486 ]
487 );
488 } else {
489 $terms = $this->get_terms( $post_ids, $this->args['include_faq'] );
490 }
491
492 if ( is_wp_error( $terms ) ) {
493 return $csv_data_terms;
494 }
495
496 // Add CSV headers for terms
497 $csv_data_terms[] = [
498 'Taxonomy',
499 'Term ID',
500 'Term name',
501 'Term slug',
502 'Term group',
503 'Term description'
504 ];
505
506 foreach ( $terms as $term ) {
507 // Add CSV row for term
508 $csv_data_terms[] = [
509 $term->taxonomy,
510 $term->term_id,
511 $term->name,
512 $term->slug,
513 $term->term_group,
514 get_term_meta( $term->term_id, 'glossary_term_description', true )
515 ];
516 }
517
518 return $csv_data_terms;
519 }
520
521 /**
522 * Return list of authors with posts.
523 *
524 * @param int[] $post_ids Optional. Array of post IDs to filter the query by.
525 *
526 * @return string
527 */
528 private function authors_list( $post_ids ) {
529 $authors = [];
530
531 // Add CSV headers for terms
532 $authors[] = [
533 'Type',
534 'Author id',
535 'Author login',
536 'Author email',
537 'Author display name',
538 'Author first name',
539 'Author last name'
540 ];
541
542 if ( ! empty( $post_ids ) ) {
543 $post_ids = array_map( 'absint', $post_ids );
544 $and = 'AND ID IN ( ' . implode( ', ', $post_ids ) . ')';
545 } else {
546 $and = '';
547 }
548 $authors_data = [];
549 $results = $this->wpdb->get_results( "SELECT DISTINCT post_author FROM {$this->wpdb->posts} WHERE post_status != 'auto-draft' $and" );// phpcs:ignore
550 foreach ( (array) $results as $r ) {
551 $authors_data[] = get_userdata( $r->post_author );
552 }
553
554 $authors_data = array_filter( $authors_data );
555
556 foreach ( $authors_data as $author ) {
557 $authors[] = [
558 'Author',
559 $author->ID,
560 $author->user_login,
561 $author->user_email,
562 $author->display_name,
563 $author->first_name,
564 $author->last_name
565 ];
566 }
567
568 return $authors;
569 }
570
571 private function get_posts_csv_data( $posts ) {
572 $csv_data_posts = [];
573
574 // Add CSV headers for posts
575 $csv_data_posts[] = [
576 'Type',
577 'Docs ID',
578 'Docs author',
579 'Docs date',
580 'Docs date gmt',
581 'Docs title',
582 'Docs content',
583 'Docs excerpt',
584 'Docs status',
585 'Docs password',
586 'Docs slug',
587 'Docs modified date',
588 'Docs modified date gmt',
589 'Docs parent',
590 'Docs menu order',
591 'Docs mime type',
592 'Comment count',
593 'Doc Categories',
594 'Doc Tags',
595 'Knowledge Bases',
596 'Docs attachement url',
597 'Docs attachement ID',
598 'Docs language code',
599 'Docs translation source slug',
600 ];
601
602 foreach ( $posts as $post ) {
603 $attachment_id = get_post_thumbnail_id( $post->ID );
604 $attachment_url = get_the_post_thumbnail_url( $post->ID );
605 $wpml = WPMLSupport::get_post_language_meta( (int) $post->ID );
606 // Add CSV row for post
607 $csv_data_posts[] = [
608 $post->post_type == 'betterdocs_faq' ? 'FAQ' : 'Docs',
609 $post->ID,
610 $post->post_author,
611 $post->post_date,
612 $post->post_date_gmt,
613 $post->post_title,
614 $post->post_content,
615 $post->post_excerpt,
616 $post->post_status,
617 $post->post_password,
618 $post->post_name,
619 $post->post_modified,
620 $post->post_modified_gmt,
621 $post->post_parent,
622 $post->menu_order,
623 $post->post_mime_type,
624 $post->comment_count,
625 $this->get_term_ids( $post->ID, $post->post_type === 'betterdocs_faq' ? [ 'betterdocs_faq_category', 'betterdocs_product_faq_category' ] : 'doc_category' ),
626 $this->get_term_ids( $post->ID, 'doc_tag' ),
627 $this->get_term_ids( $post->ID, 'knowledge_base' ),
628 $attachment_url ? $attachment_url : '',
629 $attachment_id ? $attachment_id : '',
630 $wpml ? $wpml['language_code'] : '',
631 $wpml ? $wpml['source_slug'] : '',
632 ];
633 }
634
635 return $csv_data_posts;
636 }
637
638 public function get_term_ids( $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' ] );
643
644 if ( $term_ids && ! is_wp_error( $term_ids ) ) {
645 return implode( ', ', array_map( 'intval', $term_ids ) );
646 }
647
648 return '';
649 }
650
651 private function generate_csv( array $data ): string {
652 ob_start();
653
654 $output = fopen( 'php://output', 'w' );
655
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.
660 foreach ( $data as $row ) {
661 fputcsv( $output, array_map( [ $this, 'neutralize_csv_cell' ], (array) $row ) );
662 }
663
664 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing php://output stream; WP_Filesystem does not apply.
665 fclose( $output );
666
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;
682 }
683 }
684