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 +565 -259 3.3.44.9.2 View file →
@@ -1,7 +1,9 @@
1 1 <?php
2 2 namespace WPDeveloper\BetterDocs\Admin;
3 3
4 +use Error;
5 +
4 6 if ( ! defined( 'ABSPATH' ) ) {
5 7 exit; // Exit if accessed directly.
6 8 }
7 9
@@ -6,9 +8,9 @@
6 8 }
7 9
8 10 #[\AllowDynamicProperties]
9 11 class CSVExporter {
10 - private static $default_args = [
12 + private static $default_args = [
11 13 'content' => 'docs',
12 14 'author' => false,
13 15 'category' => false,
14 16 'start_date' => false,
@@ -15,8 +17,9 @@
15 17 'end_date' => false,
16 18 'status' => false,
17 19 'offset' => 0,
18 20 'limit' => -1,
21 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- exporter accepts user-defined meta filters by design.
19 22 'meta_query' => [], // If specified `meta_key` then will include all post(s) that have this meta_key.
20 23 'query_args' => []
21 24 ];
22 25
@@ -29,9 +32,9 @@
29 32 * @var wpdb
30 33 */
31 34 private $wpdb;
32 35
33 - public function __construct( array $args = [] ) {
36 + public function __construct( array $args = [] ) {
34 37 global $wpdb;
35 38
36 39 $this->args = wp_parse_args( $args, self::$default_args );
37 40
@@ -37,207 +40,486 @@
37 40
38 41 $this->wpdb = $wpdb;
39 42 }
40 43
41 - public function combine_csv_data($csv_data_array) {
42 - // Combine headers
43 - $headers_combined = $csv_data_array[0][0];
44 + public function combine_csv_data( $csv_data_array ) {
45 + // Combine headers
46 + $headers_combined = $csv_data_array[0][0];
44 47
45 - foreach ($csv_data_array as $csv_data) {
46 - $headers_combined = array_merge($headers_combined, array_slice($csv_data[0], 1));
47 - }
48 + foreach ( $csv_data_array as $csv_data ) {
49 + $headers_combined = array_merge( $headers_combined, array_slice( $csv_data[0], 1 ) );
50 + }
48 51
49 - $csv_data_combined = [$headers_combined];
52 + $csv_data_combined = [ $headers_combined ];
50 53
51 - // Combine data
52 - for ($i = 1; $i < count($csv_data_array[0]); $i++) {
53 - $combined_row = [];
54 - foreach ($csv_data_array as $csv_data) {
55 - $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));
56 - }
57 - $csv_data_combined[] = $combined_row;
58 - }
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 + }
59 62
60 - return $csv_data_combined;
61 - }
63 + return $csv_data_combined;
64 + }
62 65
63 66
64 - public function run(): array {
65 - $post_args = [
66 - 'post_type' => 'docs',
67 - 'posts_per_page' => -1
68 - ];
67 + public function run(): array {
68 + $allowed_post_types = [ 'docs', 'betterdocs_faq' ];
69 69
70 - if ( isset($this->args['post__in']) ) {
71 - $post_args['post__in'] = $this->args['post__in'];
72 - }
70 + if ( $this->args['content'] === 'glossaries' ) {
71 + return $this->handle_glossaries_export();
72 + }
73 73
74 - if ( isset($this->args['category_terms']) ) {
75 - $post_args['tax_query'] = [
76 - [
77 - 'taxonomy' => 'doc_category',
78 - 'field' => 'slug',
79 - 'terms' => $this->args['category_terms'],
80 - ],
81 - ];
82 - }
74 + if ( ! in_array( $this->args['content'], $allowed_post_types ) ) {
75 + return [];
76 + }
83 77
84 - if ( isset($this->args['kb_terms']) ) {
85 - $post_args['tax_query'] = [
86 - [
87 - 'taxonomy' => 'knowledge_base',
88 - 'field' => 'slug',
89 - 'terms' => $this->args['kb_terms'],
90 - ],
91 - ];
92 - }
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 + );
93 85
94 - $posts = get_posts($post_args);
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 + }
95 94
96 - $post_ids = array_map(function ($post) {
97 - return $post->ID;
98 - }, $posts);
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 + }
99 103
100 - $csv_data_terms = $this->get_terms_csv_data($post_ids);
104 + $join = '';
101 105
102 - $csv_data_author = $this->authors_list($post_ids);
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 = [];
103 109
104 - $csv_data_posts = $this->get_posts_csv_data($posts);
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 + }
105 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 = [];
106 128
107 - // Combine headers
108 - $headers_combined = array_merge($csv_data_posts[0], array_slice($csv_data_author[0], 1), array_slice($csv_data_terms[0], 1));
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 + }
109 135
110 - // Initialize the combined array with headers
111 - $csv_data_combined = [$headers_combined];
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 + }
112 144
113 - // Combine term data with existing combined array
114 - for ($i = 1; $i < count($csv_data_posts); $i++) {
115 - $combined_row = array_merge($csv_data_posts[$i], array_fill(0, count($headers_combined) - count($csv_data_posts[$i]), ''));
116 - $csv_data_combined[] = $combined_row;
117 - }
145 + if ( $this->args['author'] ) {
146 + $where .= $this->wpdb->prepare(
147 + " AND {$this->wpdb->posts}.post_author = %d",
148 + $this->args['author']
149 + );
150 + }
118 151
119 - // Calculate the starting index for author data
120 - $author_start_index = count($csv_data_posts[0]);
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 + }
121 158
122 - // Combine author data with existing combined array
123 - for ($i = 1; $i < count($csv_data_author); $i++) {
124 - $combined_row = array_merge([$csv_data_author[$i][0]], array_fill(1, $author_start_index - 1, ''), array_slice($csv_data_author[$i], 1));
125 - $csv_data_combined[] = $combined_row;
126 - }
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 + }
127 165
128 - // Calculate the starting index for post data
129 - $terms_start_index = $author_start_index + count($csv_data_author[0]) - 1;
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' );
130 169
131 - // Combine post data with existing combined array
132 - for ($i = 1; $i < count($csv_data_terms); $i++) {
133 - $combined_row = array_merge([$csv_data_terms[$i][0]], array_fill(1, $terms_start_index - 1, ''), array_slice($csv_data_terms[$i], 1));
134 - $csv_data_combined[] = $combined_row;
135 - }
170 + $join .= ' ' . $query_clauses['join'];
171 + $where .= ' ' . $query_clauses['where'];
172 + }
136 173
137 - $filename = 'betterdocs.' . date('Y-m-d') . '.csv';
138 - $csv_content = $this->generate_csv($csv_data_combined);
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" );
139 177
140 - return [
141 - 'success' => true,
142 - 'data' => [
143 - 'filename' => $filename,
144 - 'filetype' => 'text/csv',
145 - 'download' => $csv_content,
146 - ],
147 - ];
148 - }
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 + }
149 192
150 - public function get_terms( array $post_ids ) {
151 - return wp_get_object_terms( $post_ids, get_object_taxonomies( 'docs' ) );
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 + ];
152 278 }
153 279
154 - /**
155 - * Sorting function for sorting term data.
156 - *
157 - * @param array $a The first array to compare.
158 - * @param array $b The second array to compare.
159 - *
160 - * @return int Returns an integer less than, equal to, or greater than zero if the first array is considered
161 - * to be respectively less than, equal to, or greater than the second.
162 - */
163 - public function sort_terms($a, $b) {
164 - // Compare parent values
165 - $parentComparison = strcmp($a[7], $b[7]);
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 + ];
166 290
167 - // If parent values are equal, compare Term ID values
168 - return ($parentComparison === 0) ? strcmp($a[2], $b[2]) : $parentComparison;
169 - }
291 + if ( $include_faq ) {
292 + array_push( $post_types, 'betterdocs_faq' );
293 + }
170 294
171 - private function get_terms_csv_data($post_ids) {
172 - $csv_data_terms = [];
295 + // Get the object taxonomies
296 + $taxonomies = get_object_taxonomies( $post_types );
173 297
174 - // Add CSV headers for terms
175 - $csv_data_terms[] = [
176 - 'Type',
177 - 'Taxonomy',
178 - 'Term ID',
179 - 'Term name',
180 - 'Term slug',
181 - 'Term group',
182 - 'Term description',
183 - 'Term parent',
184 - 'Assigned Docs',
185 - 'Assigned KBs',
186 - 'Doc Category order',
187 - 'KB order', // Add additional term meta headers here
188 - ];
298 + // Get the object terms with parent terms coming before their child terms
299 + $terms = wp_get_object_terms( $post_ids, $taxonomies );
189 300
190 - $terms = $this->get_terms($post_ids);
301 + usort( $terms, array( $this, 'compare_terms_by_meta' ) );
191 302
192 - foreach ($terms as $term) {
193 - $term_meta = '';
303 + return $terms;
304 + }
194 305
195 - // Add term meta based on taxonomy
196 - switch ($term->taxonomy) {
197 - case 'doc_category':
198 - $doc_category_knowledge_base = maybe_unserialize(get_term_meta($term->term_id, 'doc_category_knowledge_base', true));
199 - if (is_array($doc_category_knowledge_base) && $doc_category_knowledge_base !== false) {
200 - $doc_category_knowledge_base = implode(', ', array_filter($doc_category_knowledge_base));
201 - } else {
202 - $doc_category_knowledge_base = '';
203 - }
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 + );
204 323
205 - $term_meta = [
206 - '_docs_order' => get_term_meta($term->term_id, '_docs_order', true),
207 - 'doc_category_knowledge_base' => $doc_category_knowledge_base,
208 - 'doc_category_order' => get_term_meta($term->term_id, 'doc_category_order', true),
209 - ];
210 - break;
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;
211 327
212 - case 'knowledge_base':
213 - $term_meta = [
214 - 'kb_order' => get_term_meta($term->term_id, 'kb_order', true),
215 - ];
216 - break;
217 - }
328 + // If the taxonomies have different order, sort by order
329 + if ( $order_a !== $order_b ) {
330 + return $order_a - $order_b;
331 + }
218 332
219 - // Add CSV row for term
220 - $csv_data_terms[] = [
221 - 'Term',
222 - $term->taxonomy,
223 - $term->term_id,
224 - $term->name,
225 - $term->slug,
226 - $term->term_group,
227 - $term->description,
228 - $term->parent,
229 - isset($term_meta['_docs_order']) ? $term_meta['_docs_order'] : '',
230 - isset($term_meta['doc_category_knowledge_base']) ? $term_meta['doc_category_knowledge_base'] : '',
231 - isset($term_meta['doc_category_order']) ? $term_meta['doc_category_order'] : '',
232 - isset($term_meta['kb_order']) ? $term_meta['kb_order'] : '',
233 - ];
234 - }
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 + );
235 338
236 - return $csv_data_terms;
237 - }
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 ) );
238 342
239 - /**
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 + /**
240 522 * Return list of authors with posts.
241 523 *
242 524 * @param int[] $post_ids Optional. Array of post IDs to filter the query by.
243 525 *
@@ -245,18 +527,18 @@
245 527 */
246 528 private function authors_list( $post_ids ) {
247 529 $authors = [];
248 530
249 - // Add CSV headers for terms
250 - $authors[] = [
251 - 'Type',
252 - 'Author id',
253 - 'Author login',
254 - 'Author email',
255 - 'Author display name',
256 - 'Author first name',
257 - 'Author last name'
258 - ];
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 + ];
259 541
260 542 if ( ! empty( $post_ids ) ) {
261 543 $post_ids = array_map( 'absint', $post_ids );
262 544 $and = 'AND ID IN ( ' . implode( ', ', $post_ids ) . ')';
@@ -262,9 +544,9 @@
262 544 $and = 'AND ID IN ( ' . implode( ', ', $post_ids ) . ')';
263 545 } else {
264 546 $and = '';
265 547 }
266 - $authors_data = [];
548 + $authors_data = [];
267 549 $results = $this->wpdb->get_results( "SELECT DISTINCT post_author FROM {$this->wpdb->posts} WHERE post_status != 'auto-draft' $and" );// phpcs:ignore
268 550 foreach ( (array) $results as $r ) {
269 551 $authors_data[] = get_userdata( $r->post_author );
270 552 }
@@ -271,107 +553,131 @@
271 553
272 554 $authors_data = array_filter( $authors_data );
273 555
274 556 foreach ( $authors_data as $author ) {
275 - $authors[] = [
276 - 'Author',
277 - $author->ID,
278 - $author->user_login,
279 - $author->user_email,
280 - $author->display_name,
281 - $author->first_name,
282 - $author->last_name
283 - ];
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 + ];
284 566 }
285 567
286 568 return $authors;
287 569 }
288 570
289 - private function get_posts_csv_data($posts) {
290 - $csv_data_posts = [];
571 + private function get_posts_csv_data( $posts ) {
572 + $csv_data_posts = [];
291 573
292 - // Add CSV headers for posts
293 - $csv_data_posts[] = [
294 - 'Type',
295 - 'Docs ID',
296 - 'Docs author',
297 - 'Docs date',
298 - 'Docs date gmt',
299 - 'Docs title',
300 - 'Docs content',
301 - 'Docs excerpt',
302 - 'Docs status',
303 - 'Docs password',
304 - 'Docs slug',
305 - 'Docs modified date',
306 - 'Docs modified date gmt',
307 - 'Docs parent',
308 - 'Docs menu order',
309 - 'Docs mime type',
310 - 'Comment count',
311 - 'Doc Categories',
312 - 'Doc Tags',
313 - 'Knowledge Bases',
314 - 'Docs attachement url',
315 - 'Docs attachement ID'
316 - ];
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 + ];
317 601
318 - foreach ($posts as $post) {
319 - $attachment_id = get_post_thumbnail_id($post->ID);
320 - $attachment_url = get_the_post_thumbnail_url($post->ID);
321 - // Add CSV row for post
322 - $csv_data_posts[] = [
323 - 'Docs',
324 - $post->ID,
325 - $post->post_author,
326 - $post->post_date,
327 - $post->post_date_gmt,
328 - $post->post_title,
329 - $post->post_content,
330 - $post->post_excerpt,
331 - $post->post_status,
332 - $post->post_password,
333 - $post->post_name,
334 - $post->post_modified,
335 - $post->post_modified_gmt,
336 - $post->post_parent,
337 - $post->menu_order,
338 - $post->post_mime_type,
339 - $post->comment_count,
340 - $this->get_term_ids( $post->ID, 'doc_category' ),
341 - $this->get_term_ids( $post->ID, 'doc_tag' ),
342 - $this->get_term_ids( $post->ID, 'knowledge_base' ),
343 - $attachment_url ? $attachment_url : '',
344 - $attachment_id ? $attachment_id : ''
345 - ];
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 + }
346 634
347 - }
635 + return $csv_data_posts;
636 + }
348 637
349 - return $csv_data_posts;
350 - }
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' ] );
351 643
352 - public function get_term_ids($post_id, $taxonomy) {
353 - $terms = get_the_terms($post_id, $taxonomy);
644 + if ( $term_ids && ! is_wp_error( $term_ids ) ) {
645 + return implode( ', ', array_map( 'intval', $term_ids ) );
646 + }
354 647
355 - if ($terms && !is_wp_error($terms)) {
356 - $term_ids = wp_list_pluck($terms, 'term_id');
357 - return implode(', ', $term_ids);
358 - }
648 + return '';
649 + }
359 650
360 - return '';
361 - }
651 + private function generate_csv( array $data ): string {
652 + ob_start();
362 653
363 - private function generate_csv(array $data): string {
364 - ob_start();
654 + $output = fopen( 'php://output', 'w' );
365 655
366 - $output = fopen('php://output', 'w');
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 + }
367 663
368 - // Add CSV rows
369 - foreach ($data as $row) {
370 - fputcsv($output, $row);
371 - }
664 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing php://output stream; WP_Filesystem does not apply.
665 + fclose( $output );
372 666
373 - fclose($output);
667 + return ob_get_clean();
668 + }
374 669
375 - return ob_get_clean();
376 - }
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 + }
377 683 }