PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / api / import-export-api.php

import-export-api.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.0, at inc/api/import-export-api.php

676 lines 24.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Import & Export REST API endpoints.
4 *
5 * SureDonation own-data import/export (donations, donors, campaigns, settings).
6 * Distinct from the GiveWP migration API — this reads/writes SureDonation's own
7 * export format. Route handlers are filled in by later tasks; this scaffold
8 * registers the routes and enforces the capability + nonce baseline.
9 *
10 * @package SureDonation
11 * @since 1.3.0
12 */
13
14 namespace SureDonation\Inc\API;
15
16 use SureDonation\Inc\Campaigns\Campaign_Cpt;
17 use SureDonation\Inc\Database\Tables\Donations;
18 use SureDonation\Inc\Helper;
19 use SureDonation\Inc\Import_Export\Config_IO;
20 use SureDonation\Inc\Import_Export\Csv_Exporter;
21 use SureDonation\Inc\Import_Export\Import\Column_Map;
22 use SureDonation\Inc\Import_Export\Import\Csv_File;
23 use SureDonation\Inc\Import_Export\Import\Import_Runner;
24 use WP_Error;
25 use WP_REST_Response;
26 use WP_REST_Server;
27
28 // Exit if accessed directly.
29 if ( ! defined( 'ABSPATH' ) ) {
30 exit;
31 }
32
33 /**
34 * Import & Export API class.
35 *
36 * @since 1.3.0
37 */
38 class Import_Export_API {
39
40 /**
41 * Get Import & Export endpoints.
42 *
43 * Exports are READABLE (filters passed as query args, matching the existing
44 * `/donors/export` route); imports are EDITABLE (they upload and write).
45 *
46 * @return array<string, mixed>
47 * @since 1.3.0
48 */
49 public function get_endpoints() {
50 return [
51 // --- Export (read) ---
52 '/export/donations' => [
53 'methods' => WP_REST_Server::READABLE,
54 'callback' => [ $this, 'export_donations' ],
55 'permission_callback' => [ $this, 'check_permissions' ],
56 ],
57 '/export/donors' => [
58 'methods' => WP_REST_Server::READABLE,
59 'callback' => [ $this, 'export_donors' ],
60 'permission_callback' => [ $this, 'check_permissions' ],
61 ],
62 '/export/campaigns' => [
63 'methods' => WP_REST_Server::READABLE,
64 'callback' => [ $this, 'export_campaigns' ],
65 'permission_callback' => [ $this, 'check_permissions' ],
66 ],
67 '/export/settings' => [
68 'methods' => WP_REST_Server::READABLE,
69 'callback' => [ $this, 'export_settings' ],
70 'permission_callback' => [ $this, 'check_permissions' ],
71 ],
72
73 // --- Import (write) ---
74 '/import/analyze' => [
75 'methods' => WP_REST_Server::CREATABLE,
76 'callback' => [ $this, 'import_analyze' ],
77 'permission_callback' => [ $this, 'check_permissions' ],
78 ],
79 '/import/start' => [
80 'methods' => WP_REST_Server::CREATABLE,
81 'callback' => [ $this, 'import_start' ],
82 'permission_callback' => [ $this, 'check_permissions' ],
83 ],
84 '/import/batch' => [
85 'methods' => WP_REST_Server::CREATABLE,
86 'callback' => [ $this, 'import_batch' ],
87 'permission_callback' => [ $this, 'check_permissions' ],
88 ],
89 '/import/status/(?P<id>[A-Za-z0-9\-]+)' => [
90 'methods' => WP_REST_Server::READABLE,
91 'callback' => [ $this, 'import_status' ],
92 'permission_callback' => [ $this, 'check_permissions' ],
93 ],
94 '/import/campaigns' => [
95 'methods' => WP_REST_Server::CREATABLE,
96 'callback' => [ $this, 'import_campaigns' ],
97 'permission_callback' => [ $this, 'check_permissions' ],
98 ],
99 '/import/settings' => [
100 'methods' => WP_REST_Server::CREATABLE,
101 'callback' => [ $this, 'import_settings' ],
102 'permission_callback' => [ $this, 'check_permissions' ],
103 ],
104 ];
105 }
106
107 /**
108 * Capability + nonce gate for every route.
109 *
110 * Requires `manage_options`. For write methods, also verifies the
111 * `X-WP-Nonce` header (or `_wpnonce` param) against the `wp_rest` action.
112 * Mirrors the check used by the other SureDonation REST controllers.
113 *
114 * @param \WP_REST_Request|null $request Request object.
115 * @return bool|WP_Error True when allowed, WP_Error otherwise.
116 * @since 1.3.0
117 */
118 public function check_permissions( $request = null ) {
119 if ( ! current_user_can( 'manage_options' ) ) {
120 return new WP_Error(
121 'rest_forbidden',
122 __( 'You are not allowed to import or export data.', 'suredonation' ),
123 [ 'status' => rest_authorization_required_code() ]
124 );
125 }
126
127 if ( $request instanceof \WP_REST_Request ) {
128 $method = strtoupper( $request->get_method() );
129 if ( in_array( $method, [ 'POST', 'PUT', 'PATCH', 'DELETE' ], true ) ) {
130 $nonce = $request->get_header( 'X-WP-Nonce' );
131 if ( empty( $nonce ) ) {
132 $nonce_param = $request->get_param( '_wpnonce' );
133 $nonce = is_string( $nonce_param ) ? $nonce_param : '';
134 }
135 if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
136 return new WP_Error(
137 'rest_forbidden',
138 __( 'Invalid or missing nonce.', 'suredonation' ),
139 [ 'status' => 403 ]
140 );
141 }
142 }
143 }
144
145 return true;
146 }
147
148 /**
149 * Export one-time donations to CSV.
150 *
151 * Reads optional filters (payment status, campaign, payment mode, gateway,
152 * and a created_at date range), fetches the matching one-time donations, and
153 * returns the CSV as a string for the client to download. Recurring and
154 * renewal rows are excluded — the free export is one-time only. Custom form
155 * field values in donation_data['fields'] are appended as trailing columns.
156 *
157 * @param \WP_REST_Request $request Request object.
158 * @return WP_REST_Response Response with { success, csv, filename, truncated, total_count, exported }.
159 * @since 1.3.0
160 */
161 public function export_donations( $request ) {
162 $filters = [
163 'status' => sanitize_text_field( (string) $request->get_param( 'status' ) ),
164 'campaign_id' => absint( $request->get_param( 'campaign' ) ),
165 'payment_mode' => sanitize_text_field( (string) $request->get_param( 'mode' ) ),
166 'gateway' => sanitize_text_field( (string) $request->get_param( 'gateway' ) ),
167 'after' => sanitize_text_field( (string) $request->get_param( 'after' ) ),
168 'before' => sanitize_text_field( (string) $request->get_param( 'before' ) ),
169 ];
170
171 // Cap the payload at 10k rows (matches the donor export) and signal
172 // truncation so an admin knows whether the file is complete.
173 $export_cap = 10000;
174 $total_count = Donations::count_for_export( $filters );
175 $truncated = $total_count > $export_cap;
176 $donations = Donations::get_for_export( $filters, $export_cap, 0 );
177
178 // First pass: collect the union of custom-field labels so every row
179 // shares one consistent set of trailing columns.
180 $field_labels = [];
181 foreach ( $donations as $donation ) {
182 foreach ( $this->get_donation_custom_fields( $donation ) as $label => $value ) {
183 if ( ! in_array( $label, $field_labels, true ) ) {
184 $field_labels[] = $label;
185 }
186 }
187 }
188
189 $rows = [];
190 $rows[] = array_merge(
191 Column_Map::standard_donation_export_labels(),
192 $field_labels
193 );
194
195 $title_cache = [];
196 foreach ( $donations as $donation ) {
197 $campaign_id = absint( Helper::get_string_value( $donation['campaign_id'] ?? 0 ) );
198 $form_id = absint( Helper::get_string_value( $donation['form_id'] ?? 0 ) );
199
200 $row = [
201 $donation['id'] ?? '',
202 $campaign_id ? $campaign_id : '',
203 $campaign_id ? $this->resolve_title( $campaign_id, $title_cache ) : '',
204 $form_id ? $form_id : '',
205 $form_id ? $this->resolve_title( $form_id, $title_cache ) : '',
206 $donation['donor_id'] ?? '',
207 $donation['donor_name'] ?? '',
208 $donation['donor_email'] ?? '',
209 $donation['donor_phone'] ?? '',
210 $donation['amount'] ?? '',
211 $donation['fees_covered'] ?? '',
212 $donation['refunded_amount'] ?? '',
213 $donation['currency'] ?? '',
214 $donation['gateway'] ?? '',
215 $donation['payment_status'] ?? '',
216 $donation['payment_mode'] ?? '',
217 $donation['transaction_id'] ?? '',
218 $donation['donation_type'] ?? '',
219 $donation['subscription_id'] ?? '',
220 $donation['subscription_status'] ?? '',
221 ! empty( $donation['parent_subscription_id'] ) ? $donation['parent_subscription_id'] : '',
222 // Untranslated on purpose: this is interchange data, not display
223 // copy, and the importer's to_bool() matches English tokens. A
224 // translated "Ja"/"Oui" here silently imported back as not
225 // anonymous, so an export/import round trip un-masked every
226 // anonymous donor on a localised site. Every other value in this
227 // row is raw for the same reason.
228 ! empty( $donation['is_anonymous'] ) ? 'yes' : 'no',
229 $donation['donor_comment'] ?? '',
230 // Untranslated on purpose, same as the anonymity flag above: this is
231 // the column that decides whether a comment is public, so a translated
232 // or missing value on re-import would republish comments a moderator
233 // had rejected.
234 $donation['donor_comment_status'] ?? '',
235 $donation['ip_address'] ?? '',
236 $donation['created_at'] ?? '',
237 $donation['import_source'] ?? '',
238 ! empty( $donation['import_source_id'] ) ? $donation['import_source_id'] : '',
239 ];
240
241 $field_values = $this->get_donation_custom_fields( $donation );
242 foreach ( $field_labels as $label ) {
243 $row[] = $field_values[ $label ] ?? '';
244 }
245
246 $rows[] = $row;
247 }
248
249 return new WP_REST_Response(
250 [
251 'success' => true,
252 'csv' => Csv_Exporter::build( $rows ),
253 'filename' => 'suredonation-donations-export-' . gmdate( 'Y-m-d' ) . '.csv',
254 'truncated' => $truncated,
255 'total_count' => $total_count,
256 'exported' => count( $donations ),
257 ],
258 200
259 );
260 }
261
262 /**
263 * Extract a donation's submitted custom form-field values as a label => value map.
264 *
265 * @param array<string, mixed> $donation Decoded donation row.
266 * @return array<string, string> Custom-field values keyed by label.
267 * @since 1.3.0
268 */
269 private function get_donation_custom_fields( $donation ) {
270 $out = [];
271 $data = ( isset( $donation['donation_data'] ) && is_array( $donation['donation_data'] ) )
272 ? $donation['donation_data']
273 : [];
274 $fields = ( isset( $data['fields'] ) && is_array( $data['fields'] ) )
275 ? $data['fields']
276 : [];
277
278 foreach ( $fields as $field ) {
279 if ( ! is_array( $field ) || empty( $field['label'] ) ) {
280 continue;
281 }
282 $label = Helper::get_string_value( $field['label'] );
283 if ( '' !== $label ) {
284 $out[ $label ] = Helper::get_string_value( $field['value'] ?? '' );
285 }
286 }
287
288 return $out;
289 }
290
291 /**
292 * Resolve and cache a post title (campaign or form) for the export.
293 *
294 * @param int $post_id Post ID.
295 * @param array<string, string> $cache Title cache, keyed by post id, by reference.
296 * @return string Post title.
297 * @since 1.3.0
298 */
299 private function resolve_title( $post_id, &$cache ) {
300 $key = (string) $post_id;
301 if ( ! isset( $cache[ $key ] ) ) {
302 $cache[ $key ] = (string) get_the_title( $post_id );
303 }
304 return $cache[ $key ];
305 }
306
307 /**
308 * Export donors to CSV.
309 *
310 * Delegates to the existing donor export (Donors_API::export_donors_csv) so
311 * the CSV columns and query stay in one place — the Import & Export tab just
312 * exposes it under the unified /export namespace. Honors the same `search`
313 * and `campaign` query args.
314 *
315 * @param \WP_REST_Request $request Request object.
316 * @return WP_REST_Response|WP_Error CSV payload or error.
317 * @since 1.3.0
318 */
319 public function export_donors( $request ) {
320 return ( new Donors_API() )->export_donors_csv( $request );
321 }
322
323 /**
324 * Export campaigns (with their linked forms) as JSON.
325 *
326 * Optional `ids` query arg (comma-separated) limits the export to specific
327 * campaigns; omit it to export all. The response carries the JSON object the
328 * client downloads as a .json file.
329 *
330 * @param \WP_REST_Request $request Request object.
331 * @return WP_REST_Response Response with { success, data, filename, count }.
332 * @since 1.3.0
333 */
334 public function export_campaigns( $request ) {
335 $ids_param = $request->get_param( 'ids' );
336 $ids = [];
337 if ( is_string( $ids_param ) && '' !== $ids_param ) {
338 $ids = array_filter( array_map( 'absint', explode( ',', $ids_param ) ) );
339 } elseif ( is_array( $ids_param ) ) {
340 $ids = array_filter( array_map( 'absint', $ids_param ) );
341 }
342
343 $campaigns = Config_IO::export_campaigns( $ids );
344
345 return new WP_REST_Response(
346 [
347 'success' => true,
348 'data' => [
349 'type' => 'suredonation-campaigns',
350 'version' => defined( 'SUREDONATION_VER' ) ? SUREDONATION_VER : '',
351 'campaigns' => $campaigns,
352 ],
353 'filename' => 'suredonation-campaigns-export-' . gmdate( 'Y-m-d' ) . '.json',
354 'count' => count( $campaigns ),
355 ],
356 200
357 );
358 }
359
360 /**
361 * Export SureDonation settings as JSON (credentials excluded).
362 *
363 * @return WP_REST_Response Response with { success, data, filename }.
364 * @since 1.3.0
365 */
366 public function export_settings() {
367 return new WP_REST_Response(
368 [
369 'success' => true,
370 'data' => [
371 'type' => 'suredonation-settings',
372 'version' => defined( 'SUREDONATION_VER' ) ? SUREDONATION_VER : '',
373 'settings' => Config_IO::export_settings(),
374 ],
375 'filename' => 'suredonation-settings-export-' . gmdate( 'Y-m-d' ) . '.json',
376 ],
377 200
378 );
379 }
380
381 /**
382 * Analyze an uploaded CSV: store it, read the header + a sample, auto-map
383 * columns to fields, and detect the entity (donations vs donors).
384 *
385 * Returns a token the import start/batch calls reference so the same stored
386 * file is reused without re-uploading.
387 *
388 * @param \WP_REST_Request $request Request object (multipart with a `file`).
389 * @return WP_REST_Response|WP_Error Analysis payload or error.
390 * @since 1.3.0
391 */
392 public function import_analyze( $request ) {
393 $files = $request->get_file_params();
394 $file = isset( $files['file'] ) && is_array( $files['file'] ) ? $files['file'] : null;
395
396 if ( null === $file || empty( $file['tmp_name'] ) || ! empty( $file['error'] ) ) {
397 return new WP_Error( 'suredonation_no_file', __( 'No file was uploaded.', 'suredonation' ), [ 'status' => 400 ] );
398 }
399
400 $name = sanitize_file_name( (string) ( $file['name'] ?? '' ) );
401 if ( ! preg_match( '/\.csv$/i', $name ) ) {
402 return new WP_Error( 'suredonation_invalid_file', __( 'Please upload a .csv file.', 'suredonation' ), [ 'status' => 400 ] );
403 }
404
405 $size = isset( $file['size'] ) ? (int) $file['size'] : 0;
406 if ( $size <= 0 || $size > 20 * MB_IN_BYTES ) {
407 return new WP_Error( 'suredonation_invalid_size', __( 'The file is empty or larger than 20 MB.', 'suredonation' ), [ 'status' => 400 ] );
408 }
409
410 $token = Csv_File::store( (string) $file['tmp_name'] );
411 if ( false === $token ) {
412 return new WP_Error( 'suredonation_store_failed', __( 'Could not process the uploaded file.', 'suredonation' ), [ 'status' => 500 ] );
413 }
414
415 $headers = Csv_File::read_header( $token );
416 if ( empty( $headers ) ) {
417 Csv_File::delete( $token );
418 return new WP_Error( 'suredonation_empty_file', __( 'The file has no header row.', 'suredonation' ), [ 'status' => 400 ] );
419 }
420
421 $entity = sanitize_text_field( (string) $request->get_param( 'entity' ) );
422 if ( ! in_array( $entity, [ 'donations', 'donors' ], true ) ) {
423 $entity = Column_Map::detect_entity( $headers );
424 }
425
426 $mapping = Column_Map::auto_map( $headers, $entity );
427
428 $fields = [];
429 foreach ( Column_Map::fields_for( $entity ) as $key => $def ) {
430 $fields[] = [
431 'field' => $key,
432 'label' => $def['label'],
433 'required' => ! empty( $def['required'] ),
434 ];
435 }
436
437 return new WP_REST_Response(
438 [
439 'success' => true,
440 'token' => $token,
441 'entity' => $entity,
442 'headers' => $headers,
443 'mapping' => $mapping,
444 'fields' => $fields,
445 'sample' => Csv_File::read_sample( $token, 5 ),
446 'total_rows' => Csv_File::count_rows( $token ),
447 ],
448 200
449 );
450 }
451
452 /**
453 * Start a CSV import session from a previously analyzed file.
454 *
455 * @param \WP_REST_Request $request Request with token, entity, mapping, options.
456 * @return WP_REST_Response|WP_Error Session info or error.
457 * @since 1.3.0
458 */
459 public function import_start( $request ) {
460 $token = sanitize_text_field( (string) $request->get_param( 'token' ) );
461 if ( false === Csv_File::path_for( $token ) ) {
462 return new WP_Error( 'suredonation_invalid_token', __( 'The uploaded file could not be found. Please upload it again.', 'suredonation' ), [ 'status' => 400 ] );
463 }
464
465 $entity = sanitize_text_field( (string) $request->get_param( 'entity' ) );
466 if ( ! in_array( $entity, [ 'donations', 'donors' ], true ) ) {
467 return new WP_Error( 'suredonation_invalid_entity', __( 'Invalid import type.', 'suredonation' ), [ 'status' => 400 ] );
468 }
469
470 // Columns are mapped server-side from the file's header row. A
471 // SureDonation export auto-maps in full, so there is no manual mapping
472 // step; a file whose required columns don't resolve is rejected.
473 $headers = Csv_File::read_header( $token );
474 $mapping = Column_Map::auto_map( $headers, $entity );
475
476 $missing = Column_Map::missing_required( $mapping, $entity );
477 if ( ! empty( $missing ) ) {
478 return new WP_Error(
479 'suredonation_unrecognized_csv',
480 sprintf(
481 /* translators: %s: comma-separated required column labels. */
482 __( 'This does not look like a SureDonation export. Required columns were not found: %s. Please upload the CSV exported by SureDonation.', 'suredonation' ),
483 implode( ', ', $missing )
484 ),
485 [ 'status' => 400 ]
486 );
487 }
488
489 $options = [
490 'headers' => $headers,
491 'dry_run' => (bool) $request->get_param( 'dry_run' ),
492 ];
493
494 // Donations are imported into an explicitly chosen campaign.
495 if ( 'donations' === $entity ) {
496 $campaign_id = absint( $request->get_param( 'campaign_id' ) );
497 $campaign = $campaign_id ? get_post( $campaign_id ) : null;
498 if ( ! $campaign instanceof \WP_Post || Campaign_Cpt::POST_TYPE !== $campaign->post_type ) {
499 return new WP_Error( 'suredonation_invalid_campaign', __( 'Please choose a campaign to import the donations into.', 'suredonation' ), [ 'status' => 400 ] );
500 }
501 $options['campaign_id'] = $campaign_id;
502 }
503
504 $total_rows = Csv_File::count_rows( $token );
505 $progress = Import_Runner::create( $entity, $token, $mapping, $options, $total_rows );
506
507 return new WP_REST_Response(
508 [
509 'success' => true,
510 'import_id' => $progress['import_id'],
511 'total_rows' => $total_rows,
512 'batch_size' => Import_Runner::BATCH_SIZE,
513 ],
514 200
515 );
516 }
517
518 /**
519 * Process the next batch of an import session.
520 *
521 * @param \WP_REST_Request $request Request with import_id.
522 * @return WP_REST_Response|WP_Error Progress or error.
523 * @since 1.3.0
524 */
525 public function import_batch( $request ) {
526 $import_id = sanitize_text_field( (string) $request->get_param( 'import_id' ) );
527 $session = Import_Runner::get( $import_id );
528 if ( false === $session ) {
529 return new WP_Error( 'suredonation_invalid_import', __( 'Import session not found or already finished.', 'suredonation' ), [ 'status' => 400 ] );
530 }
531 if ( ! $this->user_owns_session( $session ) ) {
532 return new WP_Error( 'rest_forbidden', __( 'You cannot modify an import started by another user.', 'suredonation' ), [ 'status' => rest_authorization_required_code() ] );
533 }
534 $progress = Import_Runner::run_batch( $import_id );
535 if ( false === $progress ) {
536 return new WP_Error( 'suredonation_invalid_import', __( 'Import session not found or already finished.', 'suredonation' ), [ 'status' => 400 ] );
537 }
538 return new WP_REST_Response( $this->import_progress_response( $progress ), 200 );
539 }
540
541 /**
542 * Whether the current user may act on an import session.
543 *
544 * All callers already hold `manage_options`; this additionally scopes a
545 * session to the admin who started it. Sessions with no recorded owner
546 * fall back to the capability gate.
547 *
548 * @param array<string, mixed> $progress Session payload.
549 * @return bool
550 * @since 1.3.0
551 */
552 private function user_owns_session( $progress ) {
553 $owner = Helper::get_integer_value( $progress['started_by'] ?? 0 );
554 return 0 === $owner || get_current_user_id() === $owner;
555 }
556
557 /**
558 * Read an import session's current status.
559 *
560 * @param \WP_REST_Request $request Request with the id path param.
561 * @return WP_REST_Response|WP_Error Progress or error.
562 * @since 1.3.0
563 */
564 public function import_status( $request ) {
565 $import_id = sanitize_text_field( (string) $request->get_param( 'id' ) );
566 $progress = Import_Runner::get( $import_id );
567 if ( false === $progress ) {
568 return new WP_Error( 'suredonation_invalid_import', __( 'Import session not found.', 'suredonation' ), [ 'status' => 404 ] );
569 }
570 if ( ! $this->user_owns_session( $progress ) ) {
571 return new WP_Error( 'rest_forbidden', __( 'You cannot view an import started by another user.', 'suredonation' ), [ 'status' => rest_authorization_required_code() ] );
572 }
573 return new WP_REST_Response( $this->import_progress_response( $progress ), 200 );
574 }
575
576 /**
577 * Shape an import session's progress for the client.
578 *
579 * @param array<string, mixed> $progress Session payload.
580 * @return array<string, mixed> Trimmed progress view.
581 * @since 1.3.0
582 */
583 private function import_progress_response( $progress ) {
584 $entity = is_string( $progress['entity'] ?? null ) ? $progress['entity'] : '';
585 $results = is_array( $progress['results'] ?? null ) ? $progress['results'] : [];
586 $result = is_array( $results[ $entity ] ?? null ) ? $results[ $entity ] : [];
587
588 $processed = Helper::get_integer_value( $result['imported'] ?? 0 ) + Helper::get_integer_value( $result['skipped'] ?? 0 ) + Helper::get_integer_value( $result['errors'] ?? 0 );
589 $total = Helper::get_integer_value( $progress['total_rows'] ?? 0 );
590 $status = is_string( $progress['status'] ?? null ) ? $progress['status'] : '';
591
592 $percentage = $total > 0 ? min( 100, (int) round( $processed / $total * 100 ) ) : 100;
593 if ( 'complete' === $status ) {
594 $percentage = 100;
595 }
596
597 return [
598 'success' => true,
599 'import_id' => is_string( $progress['import_id'] ?? null ) ? $progress['import_id'] : '',
600 'status' => $status,
601 'entity' => $entity,
602 'total_rows' => $total,
603 'processed' => $processed,
604 'percentage' => $percentage,
605 'results' => $result,
606 'error' => is_string( $progress['error'] ?? null ) ? $progress['error'] : '',
607 ];
608 }
609
610 /**
611 * Import campaigns (with their forms) from an exported JSON payload.
612 *
613 * @param \WP_REST_Request $request Request with a `data` object ({ campaigns: [...] }).
614 * @return WP_REST_Response|WP_Error Result or error.
615 * @since 1.3.0
616 */
617 public function import_campaigns( $request ) {
618 $data = $request->get_param( 'data' );
619 $campaigns = ( is_array( $data ) && is_array( $data['campaigns'] ?? null ) )
620 ? $data['campaigns']
621 : [];
622
623 if ( empty( $campaigns ) ) {
624 return new WP_Error( 'suredonation_no_campaigns', __( 'No campaigns were found in the file.', 'suredonation' ), [ 'status' => 400 ] );
625 }
626
627 $result = Config_IO::import_campaigns( $campaigns );
628
629 return new WP_REST_Response(
630 [
631 'success' => true,
632 'message' => sprintf(
633 /* translators: 1: campaign count, 2: form count. */
634 __( 'Imported %1$d campaigns and %2$d forms.', 'suredonation' ),
635 $result['campaigns'],
636 $result['forms']
637 ),
638 'campaigns' => $result['campaigns'],
639 'forms' => $result['forms'],
640 ],
641 200
642 );
643 }
644
645 /**
646 * Import settings from an exported JSON payload (Merge/Replace, secret-safe).
647 *
648 * @param \WP_REST_Request $request Request with a `data` object ({ settings: {...} }) and `mode`.
649 * @return WP_REST_Response|WP_Error Result or error.
650 * @since 1.3.0
651 */
652 public function import_settings( $request ) {
653 $data = $request->get_param( 'data' );
654 $settings = ( is_array( $data ) && is_array( $data['settings'] ?? null ) )
655 ? $data['settings']
656 : [];
657 $mode = 'replace' === $request->get_param( 'mode' ) ? 'replace' : 'merge';
658
659 if ( empty( $settings ) ) {
660 return new WP_Error( 'suredonation_no_settings', __( 'No settings were found in the file.', 'suredonation' ), [ 'status' => 400 ] );
661 }
662
663 $result = Config_IO::import_settings( $settings, $mode );
664
665 return new WP_REST_Response(
666 [
667 'success' => true,
668 'message' => __( 'Settings imported.', 'suredonation' ),
669 'notice' => __( 'Payment credentials were not imported. Reconnect your gateways under Settings → Payment Methods.', 'suredonation' ),
670 'applied' => $result['applied'],
671 ],
672 200
673 );
674 }
675 }
676