PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 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 / import-export / import / column-map.php

column-map.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.1, at inc/import-export/import/column-map.php

569 lines 18.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Column mapping for the own-data CSV importer.
4 *
5 * Defines the importable fields per entity (donations, donors), their header
6 * aliases, and the auto-mapping that matches a CSV's header row to those
7 * fields. The aliases include the exact labels SureDonation's own export
8 * produces, so an exported file round-trips with a 100% auto-map.
9 *
10 * @package SureDonation
11 * @since 1.3.0
12 */
13
14 namespace SureDonation\Inc\Import_Export\Import;
15
16 use SureDonation\Inc\Helper;
17
18 // Exit if accessed directly.
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23 /**
24 * Column map / auto-mapping helper.
25 *
26 * @since 1.3.0
27 */
28 class Column_Map {
29
30 /**
31 * Importable donation fields. Each: label, aliases (lowercased header
32 * text this field auto-matches), and whether it is required.
33 *
34 * @return array<string, array<string, mixed>>
35 * @since 1.3.0
36 */
37 public static function donation_fields() {
38 return [
39 'import_source_id' => [
40 'label' => __( 'Donation ID', 'suredonation' ),
41 'aliases' => [ 'donation id', 'donation_id', 'id' ],
42 'required' => false,
43 ],
44 'donor_email' => [
45 'label' => __( 'Donor Email', 'suredonation' ),
46 'aliases' => [ 'donor email', 'email', 'email address', 'donor_email' ],
47 'required' => true,
48 ],
49 'amount' => [
50 'label' => __( 'Amount', 'suredonation' ),
51 'aliases' => [ 'amount', 'donation amount', 'donation total', 'total' ],
52 'required' => true,
53 ],
54 'donor_name' => [
55 'label' => __( 'Donor Name', 'suredonation' ),
56 'aliases' => [ 'donor name', 'name', 'full name', 'donor_name' ],
57 'required' => false,
58 ],
59 'first_name' => [
60 'label' => __( 'First Name', 'suredonation' ),
61 'aliases' => [ 'first name', 'first_name' ],
62 'required' => false,
63 ],
64 'last_name' => [
65 'label' => __( 'Last Name', 'suredonation' ),
66 'aliases' => [ 'last name', 'last_name' ],
67 'required' => false,
68 ],
69 'donor_phone' => [
70 'label' => __( 'Donor Phone', 'suredonation' ),
71 'aliases' => [ 'donor phone', 'phone', 'phone number', 'donor_phone' ],
72 'required' => false,
73 ],
74 'company' => [
75 'label' => __( 'Company', 'suredonation' ),
76 'aliases' => [ 'company', 'company name' ],
77 'required' => false,
78 ],
79 'address' => [
80 'label' => __( 'Address', 'suredonation' ),
81 'aliases' => [ 'address' ],
82 'required' => false,
83 ],
84 'campaign' => [
85 'label' => __( 'Campaign', 'suredonation' ),
86 'aliases' => [ 'campaign', 'campaign name', 'campaign title', 'campaign id', 'campaign_id' ],
87 'required' => false,
88 ],
89 'form' => [
90 'label' => __( 'Form', 'suredonation' ),
91 'aliases' => [ 'form', 'form title', 'form id', 'form_id' ],
92 'required' => false,
93 ],
94 'currency' => [
95 'label' => __( 'Currency', 'suredonation' ),
96 'aliases' => [ 'currency', 'currency code' ],
97 'required' => false,
98 ],
99 'gateway' => [
100 'label' => __( 'Gateway', 'suredonation' ),
101 'aliases' => [ 'gateway', 'payment gateway', 'payment method', 'method' ],
102 'required' => false,
103 ],
104 'payment_status' => [
105 'label' => __( 'Payment Status', 'suredonation' ),
106 'aliases' => [ 'payment status', 'status', 'donation status' ],
107 'required' => false,
108 ],
109 'payment_mode' => [
110 'label' => __( 'Payment Mode', 'suredonation' ),
111 'aliases' => [ 'payment mode', 'mode', 'test mode' ],
112 'required' => false,
113 ],
114 'transaction_id' => [
115 'label' => __( 'Transaction ID', 'suredonation' ),
116 'aliases' => [ 'transaction id', 'transaction_id' ],
117 'required' => false,
118 ],
119 'fees_covered' => [
120 'label' => __( 'Fees Covered', 'suredonation' ),
121 'aliases' => [ 'fees covered', 'fees_covered', 'fee covered' ],
122 'required' => false,
123 ],
124 'refunded_amount' => [
125 'label' => __( 'Refunded Amount', 'suredonation' ),
126 'aliases' => [ 'refunded amount', 'refunded_amount' ],
127 'required' => false,
128 ],
129 'is_anonymous' => [
130 'label' => __( 'Anonymous', 'suredonation' ),
131 'aliases' => [ 'anonymous', 'is anonymous', 'is_anonymous' ],
132 'required' => false,
133 ],
134 'donation_type' => [
135 'label' => __( 'Donation Type', 'suredonation' ),
136 'aliases' => [ 'donation type', 'type', 'donation_type' ],
137 'required' => false,
138 ],
139 'subscription_id' => [
140 'label' => __( 'Subscription ID', 'suredonation' ),
141 'aliases' => [ 'subscription id', 'subscription_id' ],
142 'required' => false,
143 ],
144 'subscription_status' => [
145 'label' => __( 'Subscription Status', 'suredonation' ),
146 'aliases' => [ 'subscription status', 'subscription_status' ],
147 'required' => false,
148 ],
149 'parent_subscription_id' => [
150 'label' => __( 'Parent Subscription ID', 'suredonation' ),
151 'aliases' => [ 'parent subscription id', 'parent_subscription_id' ],
152 'required' => false,
153 ],
154 'donor_comment' => [
155 'label' => __( 'Donor Comment', 'suredonation' ),
156 'aliases' => [ 'donor comment', 'comment', 'donor_comment' ],
157 'required' => false,
158 ],
159 'donor_comment_status' => [
160 'label' => __( 'Comment Status', 'suredonation' ),
161 'aliases' => [ 'comment status', 'donor comment status', 'donor_comment_status' ],
162 'required' => false,
163 ],
164 'ip_address' => [
165 'label' => __( 'IP Address', 'suredonation' ),
166 'aliases' => [ 'ip address', 'ip', 'ip_address' ],
167 'required' => false,
168 ],
169 'donation_date' => [
170 'label' => __( 'Date', 'suredonation' ),
171 'aliases' => [ 'date', 'donation date', 'created at', 'created_at', 'donation_date' ],
172 'required' => false,
173 ],
174 ];
175 }
176
177 /**
178 * Importable donor fields.
179 *
180 * @return array<string, array<string, mixed>>
181 * @since 1.3.0
182 */
183 public static function donor_fields() {
184 return [
185 'import_source_id' => [
186 'label' => __( 'Donor ID', 'suredonation' ),
187 'aliases' => [ 'donor id', 'donor_id', 'id' ],
188 'required' => false,
189 ],
190 'email' => [
191 'label' => __( 'Email', 'suredonation' ),
192 'aliases' => [ 'email', 'donor email', 'email address' ],
193 'required' => true,
194 ],
195 'name' => [
196 'label' => __( 'Name', 'suredonation' ),
197 'aliases' => [ 'name', 'donor name', 'full name' ],
198 'required' => false,
199 ],
200 'phone' => [
201 'label' => __( 'Phone', 'suredonation' ),
202 'aliases' => [ 'phone', 'phone number', 'donor phone' ],
203 'required' => false,
204 ],
205 'company' => [
206 'label' => __( 'Company', 'suredonation' ),
207 'aliases' => [ 'company', 'company name' ],
208 'required' => false,
209 ],
210 'address' => [
211 'label' => __( 'Address', 'suredonation' ),
212 'aliases' => [ 'address' ],
213 'required' => false,
214 ],
215 'donor_status' => [
216 'label' => __( 'Status', 'suredonation' ),
217 'aliases' => [ 'status', 'donor status' ],
218 'required' => false,
219 ],
220 'donor_tags' => [
221 'label' => __( 'Tags', 'suredonation' ),
222 'aliases' => [ 'tags', 'donor tags' ],
223 'required' => false,
224 ],
225 'total_donated' => [
226 'label' => __( 'Total Donated', 'suredonation' ),
227 'aliases' => [ 'total donated', 'total_donated' ],
228 'required' => false,
229 ],
230 'donation_count' => [
231 'label' => __( 'Donation Count', 'suredonation' ),
232 'aliases' => [ 'donation count', 'donation_count', 'number of donations' ],
233 'required' => false,
234 ],
235 'largest_donation' => [
236 'label' => __( 'Largest Donation', 'suredonation' ),
237 'aliases' => [ 'largest donation', 'largest_donation' ],
238 'required' => false,
239 ],
240 'first_donation_date' => [
241 'label' => __( 'First Donation', 'suredonation' ),
242 'aliases' => [ 'first donation', 'first donation date', 'first_donation_date' ],
243 'required' => false,
244 ],
245 'last_donation_date' => [
246 'label' => __( 'Last Donation', 'suredonation' ),
247 'aliases' => [ 'last donation', 'last donation date', 'last_donation_date' ],
248 'required' => false,
249 ],
250 'created_at' => [
251 'label' => __( 'Created At', 'suredonation' ),
252 'aliases' => [ 'created at', 'created_at' ],
253 'required' => false,
254 ],
255 ];
256 }
257
258 /**
259 * Get the field definitions for an entity.
260 *
261 * @param string $entity 'donations' or 'donors'.
262 * @return array<string, array<string, mixed>> Field definitions.
263 * @since 1.3.0
264 */
265 public static function fields_for( $entity ) {
266 return 'donors' === $entity ? self::donor_fields() : self::donation_fields();
267 }
268
269 /**
270 * Auto-map a CSV header row to entity fields.
271 *
272 * Case-insensitive exact match of each (trimmed) header against the field
273 * aliases; each field is auto-assigned to at most one header (first match
274 * wins), so duplicate-labelled columns don't collide.
275 *
276 * @param array<int, string> $headers CSV header cells.
277 * @param string $entity 'donations' or 'donors'.
278 * @return array<int, string> Map of header index => field key ('' = unmapped).
279 * @since 1.3.0
280 */
281 public static function auto_map( $headers, $entity ) {
282 $fields = self::fields_for( $entity );
283 $used = [];
284 $map = [];
285
286 foreach ( $headers as $index => $header ) {
287 $needle = strtolower( trim( (string) $header ) );
288 $map[ $index ] = '';
289
290 if ( '' === $needle ) {
291 continue;
292 }
293
294 foreach ( $fields as $field => $def ) {
295 if ( isset( $used[ $field ] ) ) {
296 continue;
297 }
298 if ( in_array( $needle, self::aliases_for( $def ), true ) ) {
299 $map[ $index ] = $field;
300 $used[ $field ] = true;
301 break;
302 }
303 }
304 }
305
306 return $map;
307 }
308
309 /**
310 * The header text a field auto-matches: its declared (English) aliases plus
311 * its own localized label.
312 *
313 * The exporters write translated labels as the header row, so on a non-English
314 * site the site's own export would not match the English aliases at all. The
315 * label is therefore an implicit alias, which keeps the round-trip working in
316 * every locale while still accepting the English headers of a hand-made file.
317 *
318 * @param array<string, mixed> $def Field definition.
319 * @return array<int, string> Lowercased header text this field matches.
320 * @since 1.3.0
321 */
322 private static function aliases_for( $def ) {
323 $aliases = isset( $def['aliases'] ) && is_array( $def['aliases'] ) ? $def['aliases'] : [];
324 $label = strtolower( trim( Helper::get_string_value( $def['label'] ?? '' ) ) );
325
326 if ( '' !== $label && ! in_array( $label, $aliases, true ) ) {
327 $aliases[] = $label;
328 }
329
330 return $aliases;
331 }
332
333 /**
334 * Header text that signals an entity, collected from the given fields'
335 * aliases and localized labels.
336 *
337 * @param string $entity 'donations' or 'donors'.
338 * @param array<int,string> $keys Field keys that only that entity exports.
339 * @return array<int, string> Lowercased signal header text.
340 * @since 1.3.0
341 */
342 private static function signals_for( $entity, $keys ) {
343 $fields = self::fields_for( $entity );
344 $signals = [];
345
346 foreach ( $keys as $key ) {
347 if ( isset( $fields[ $key ] ) ) {
348 $signals = array_merge( $signals, self::aliases_for( $fields[ $key ] ) );
349 }
350 }
351
352 return array_values( array_unique( $signals ) );
353 }
354
355 /**
356 * Guess whether a header row describes donations or donors.
357 *
358 * Scores the headers against each entity's donor-only / donation-only
359 * signal columns; the higher score wins, defaulting to donations on a tie.
360 * Signals are derived from the field definitions so they carry the localized
361 * labels too — otherwise a translated export scores zero on both sides.
362 *
363 * @param array<int, string> $headers CSV header cells.
364 * @return string 'donations' or 'donors'.
365 * @since 1.3.0
366 */
367 public static function detect_entity( $headers ) {
368 $normalized = array_map(
369 static function ( $header ) {
370 return strtolower( trim( (string) $header ) );
371 },
372 $headers
373 );
374
375 $donation_signals = self::signals_for( 'donations', [ 'amount', 'payment_status', 'transaction_id', 'gateway' ] );
376 $donor_signals = self::signals_for( 'donors', [ 'total_donated', 'donation_count', 'largest_donation' ] );
377
378 $donation_score = count( array_intersect( $normalized, $donation_signals ) );
379 $donor_score = count( array_intersect( $normalized, $donor_signals ) );
380
381 return $donor_score > $donation_score ? 'donors' : 'donations';
382 }
383
384 /**
385 * Apply a column mapping to a raw CSV row, producing a field => value map.
386 *
387 * @param array<int, string> $row Raw cell values (column order).
388 * @param array<int|string, mixed> $mapping Header index => field key.
389 * @return array<string, string> Field => value (unmapped columns dropped).
390 * @since 1.3.0
391 */
392 public static function apply_row( $row, $mapping ) {
393 $data = [];
394 if ( ! is_array( $mapping ) ) {
395 return $data;
396 }
397 foreach ( $mapping as $index => $field ) {
398 $field_key = Helper::get_string_value( $field );
399 if ( '' === $field_key ) {
400 continue;
401 }
402 $data[ $field_key ] = isset( $row[ $index ] ) ? (string) $row[ $index ] : '';
403 }
404 return $data;
405 }
406
407 /**
408 * The fixed set of standard column labels a SureDonation donations export
409 * emits, in order, before any custom form-field columns.
410 *
411 * Single source of truth: the exporter uses this for its header row, and the
412 * importer treats any column NOT in this set as a submitted custom field. The
413 * order here must match the value order the exporter writes per row.
414 *
415 * @return array<int, string> Column labels.
416 * @since 1.3.0
417 */
418 public static function standard_donation_export_labels() {
419 return [
420 __( 'Donation ID', 'suredonation' ),
421 __( 'Campaign ID', 'suredonation' ),
422 __( 'Campaign Name', 'suredonation' ),
423 __( 'Form ID', 'suredonation' ),
424 __( 'Form Title', 'suredonation' ),
425 __( 'Donor ID', 'suredonation' ),
426 __( 'Donor Name', 'suredonation' ),
427 __( 'Donor Email', 'suredonation' ),
428 __( 'Donor Phone', 'suredonation' ),
429 __( 'Amount', 'suredonation' ),
430 __( 'Fees Covered', 'suredonation' ),
431 __( 'Refunded Amount', 'suredonation' ),
432 __( 'Currency', 'suredonation' ),
433 __( 'Gateway', 'suredonation' ),
434 __( 'Payment Status', 'suredonation' ),
435 __( 'Payment Mode', 'suredonation' ),
436 __( 'Transaction ID', 'suredonation' ),
437 __( 'Donation Type', 'suredonation' ),
438 __( 'Subscription ID', 'suredonation' ),
439 __( 'Subscription Status', 'suredonation' ),
440 __( 'Parent Subscription ID', 'suredonation' ),
441 __( 'Anonymous', 'suredonation' ),
442 __( 'Donor Comment', 'suredonation' ),
443 __( 'Comment Status', 'suredonation' ),
444 __( 'IP Address', 'suredonation' ),
445 __( 'Date', 'suredonation' ),
446 __( 'Import Source', 'suredonation' ),
447 __( 'Import Source ID', 'suredonation' ),
448 ];
449 }
450
451 /**
452 * The trailing export column a donation's custom fields spill into once
453 * they exceed the exporter's per-export column cap.
454 *
455 * Single source of truth shared with Import_Export_Api::export_donations(),
456 * which writes this same label as a plain JSON object of label => value.
457 * Kept separate from standard_donation_export_labels() (which the importer
458 * treats as columns to skip entirely) because this one instead needs its
459 * own value decoded and expanded — see extract_custom_fields().
460 *
461 * Deliberately untranslated: this same string is written on export and
462 * matched on import (extract_custom_fields()), so an export made under one
463 * site locale would fail to match on import under another, silently
464 * storing the JSON blob as one literal field instead of expanding it —
465 * the exact corruption this column exists to prevent. Interchange data,
466 * not display copy, same reasoning as the anonymous yes/no export tokens.
467 *
468 * @return string Column label.
469 * @since 1.6.1
470 */
471 public static function other_fields_export_label() {
472 return 'Other Fields';
473 }
474
475 /**
476 * Pull submitted custom form-field values out of a donation row: any column
477 * whose header is not a standard export column is treated as a custom field
478 * (these are the per-form fields flattened into trailing export columns).
479 *
480 * The "Other Fields" column is the one exception: it is itself a JSON object
481 * of label => value for whatever didn't fit in the export's own column cap,
482 * so its cell is decoded and expanded into individual fields rather than
483 * stored verbatim as one field literally named "Other Fields" — otherwise a
484 * re-import of a capped export would silently corrupt donation_data with a
485 * single bogus field holding a raw JSON blob.
486 *
487 * @param array<int, string> $headers CSV header cells.
488 * @param array<int, string> $row Raw row cells (column order).
489 * @return array<string, array{label: string, value: string}> Custom fields keyed by slug.
490 * @since 1.3.0
491 */
492 public static function extract_custom_fields( $headers, $row ) {
493 $standard = array_map(
494 static function ( $label ) {
495 return strtolower( trim( (string) $label ) );
496 },
497 self::standard_donation_export_labels()
498 );
499
500 $other_fields_label = strtolower( trim( self::other_fields_export_label() ) );
501
502 $fields = [];
503 foreach ( $headers as $index => $header ) {
504 $label = trim( (string) $header );
505 if ( '' === $label || in_array( strtolower( $label ), $standard, true ) ) {
506 continue;
507 }
508
509 $value = isset( $row[ $index ] ) ? (string) $row[ $index ] : '';
510 if ( '' === $value ) {
511 continue;
512 }
513
514 if ( strtolower( $label ) === $other_fields_label ) {
515 $overflow = json_decode( $value, true );
516 if ( is_array( $overflow ) ) {
517 foreach ( $overflow as $overflow_label => $overflow_value ) {
518 $overflow_label = trim( (string) $overflow_label );
519 if ( '' === $overflow_label || ! is_scalar( $overflow_value ) ) {
520 continue;
521 }
522 $overflow_key = sanitize_key( $overflow_label );
523 if ( '' === $overflow_key ) {
524 continue;
525 }
526 $fields[ $overflow_key ] = [
527 'label' => sanitize_text_field( $overflow_label ),
528 'value' => sanitize_text_field( (string) $overflow_value ),
529 ];
530 }
531 }
532 continue;
533 }
534
535 $key = sanitize_key( $label );
536 if ( '' === $key ) {
537 $key = 'field_' . (int) $index;
538 }
539 $fields[ $key ] = [
540 'label' => sanitize_text_field( $label ),
541 'value' => sanitize_text_field( $value ),
542 ];
543 }
544 return $fields;
545 }
546
547 /**
548 * Field keys that are required but missing from a mapping.
549 *
550 * @param array<int, string> $mapping Header index => field key.
551 * @param string $entity 'donations' or 'donors'.
552 * @return array<int, string> Missing required field labels.
553 * @since 1.3.0
554 */
555 public static function missing_required( $mapping, $entity ) {
556 $fields = self::fields_for( $entity );
557 $mapped = array_values( array_filter( $mapping ) );
558 $missing = [];
559
560 foreach ( $fields as $field => $def ) {
561 if ( ! empty( $def['required'] ) && ! in_array( $field, $mapped, true ) ) {
562 $missing[] = Helper::get_string_value( $def['label'] );
563 }
564 }
565
566 return $missing;
567 }
568 }
569