PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.2
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.2
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 / givewp / campaign-mapper.php

campaign-mapper.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.1.2, at inc/import/givewp/campaign-mapper.php

391 lines 13.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Campaign mapper for the GiveWP migration tool.
4 *
5 * Translates GiveWP forms (give_forms CPT) into SureDonation campaigns
6 * (suredonation_cmpgn CPT) and creates a default donation form for each.
7 * Imports campaigns as `publish` (post_status) with `campaign_status =
8 * active` so the donor-facing campaign page is immediately reachable
9 * after migration — admins can still flip an individual campaign back
10 * to draft from the editor if they want to review before going live.
11 *
12 * @package SureDonation
13 */
14
15 namespace SureDonation\Inc\Import\Givewp;
16
17 use SureDonation\Inc\Campaigns\Campaign_Cpt;
18 use SureDonation\Inc\Helper;
19 use SureDonation\Inc\Post_Types\Donation_Form;
20 use SureDonation\Inc\Traits\Get_Instance;
21
22 // Exit if accessed directly.
23 defined( 'ABSPATH' ) || exit;
24
25 /**
26 * Campaign_Mapper class.
27 *
28 * @since 1.0.0
29 */
30 class Campaign_Mapper {
31 use Get_Instance;
32
33 /**
34 * Post meta key that stores the GiveWP source ID on imported campaigns.
35 * Used both for duplicate detection and for Pro rollback.
36 */
37 const META_SOURCE_ID = '_suredonation_givewp_source_id';
38
39 /**
40 * Post meta key that ties an imported campaign to its migration session.
41 * Used by Pro rollback to scope deletion to a single import.
42 */
43 const META_IMPORT_ID = '_suredonation_givewp_import_id';
44
45 /**
46 * Post meta key on the GiveWP form recording the SureDonation campaign ID.
47 * Used by Pro rollback to clear the link if the campaign gets removed.
48 */
49 const META_GIVEWP_MARKER = '_suredonation_imported_to_campaign_id';
50
51 /**
52 * Process a batch of GiveWP forms.
53 *
54 * @param array<string,mixed> $progress Session progress (passed by reference).
55 * @param int $offset Current offset within this phase.
56 * @return int Number of source rows processed in this batch.
57 * @since 1.0.0
58 */
59 public function process_batch( &$progress, $offset ) {
60 $source = Source::get_instance();
61 $form_ids = isset( $progress['options']['campaign_ids'] ) && is_array( $progress['options']['campaign_ids'] )
62 ? $progress['options']['campaign_ids']
63 : [];
64 $forms = $source->get_forms_batch( (int) $offset, Importer::BATCH_SIZE, $form_ids );
65
66 if ( empty( $forms ) ) {
67 return 0;
68 }
69
70 foreach ( $forms as $form ) {
71 $give_form_id = isset( $form->ID ) ? (int) $form->ID : 0; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- WP $wpdb->posts row.
72 if ( $give_form_id <= 0 ) {
73 ++$progress['results']['campaigns']['errors'];
74 continue;
75 }
76
77 $existing_id = $this->find_existing_campaign( $give_form_id );
78
79 if ( $existing_id > 0 ) {
80 ++$progress['results']['campaigns']['skipped'];
81 $progress['campaign_map'][ $give_form_id ] = $existing_id;
82 continue;
83 }
84
85 try {
86 $result = $this->insert_campaign( $form, $progress );
87 } catch ( \Throwable $t ) {
88 ++$progress['results']['campaigns']['errors'];
89 $progress['results']['campaigns']['error_log'] = $this->append_error_log(
90 $progress['results']['campaigns']['error_log'],
91 $give_form_id,
92 sprintf(
93 /* translators: %s: exception message */
94 __( 'Unhandled exception while importing campaign: %s', 'suredonation' ),
95 $t->getMessage()
96 )
97 );
98 continue;
99 }
100
101 if ( is_wp_error( $result ) ) {
102 ++$progress['results']['campaigns']['errors'];
103 $progress['results']['campaigns']['error_log'] = $this->append_error_log(
104 $progress['results']['campaigns']['error_log'],
105 $give_form_id,
106 $result->get_error_message()
107 );
108 continue;
109 }
110
111 $progress['campaign_map'][ $give_form_id ] = (int) $result;
112 ++$progress['results']['campaigns']['imported'];
113 }
114
115 return count( $forms );
116 }
117
118 /**
119 * Find an existing SureDonation campaign for a given GiveWP form ID.
120 *
121 * @param int $give_form_id GiveWP form ID.
122 * @return int Campaign ID or 0 if none.
123 * @since 1.0.0
124 */
125 private function find_existing_campaign( $give_form_id ) {
126 global $wpdb;
127
128 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Migration scope, one-shot lookup per form.
129 $post_id = $wpdb->get_var(
130 $wpdb->prepare(
131 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1",
132 self::META_SOURCE_ID,
133 (string) (int) $give_form_id
134 )
135 );
136
137 return is_numeric( $post_id ) ? (int) $post_id : 0;
138 }
139
140 /**
141 * Insert a single SureDonation campaign from a GiveWP form row.
142 *
143 * Migrates title, description (short + long), goal amount, goal type,
144 * dates, brand colors, hero image, and campaign-page URL.
145 *
146 * GiveWP 4 stores rich campaign data in the `wp_give_campaigns`
147 * custom table keyed by `form_id`. GiveWP 3.x and earlier kept the
148 * goal in `_give_set_goal` post meta. Both paths are handled.
149 *
150 * @param object $form GiveWP wp_posts row.
151 * @param array $progress Current progress (used for the import_id).
152 * @return int|\WP_Error New campaign ID on success.
153 * @since 1.0.0
154 */
155 private function insert_campaign( $form, $progress ) {
156 $source = Source::get_instance();
157
158 $form_id = isset( $form->ID ) ? (int) $form->ID : 0;
159 $post_title = isset( $form->post_title ) ? (string) $form->post_title : '';
160 $post_content = isset( $form->post_content ) ? (string) $form->post_content : '';
161 $post_author = isset( $form->post_author ) ? (int) $form->post_author : get_current_user_id();
162 $post_date = isset( $form->post_date ) ? (string) $form->post_date : current_time( 'mysql' );
163 $post_date_gmt = isset( $form->post_date_gmt ) ? (string) $form->post_date_gmt : current_time( 'mysql', true );
164
165 // Prefer the GiveWP 4 campaign row when it exists.
166 $campaign = $source->get_campaign_for_form( $form_id );
167
168 $long_desc = '';
169 $short_desc = '';
170 if ( is_array( $campaign ) ) {
171 if ( ! empty( $campaign['campaign_title'] ) ) {
172 $post_title = (string) $campaign['campaign_title'];
173 }
174 $long_desc = self::clean_description(
175 isset( $campaign['long_desc'] ) ? $campaign['long_desc'] : ''
176 );
177 $short_desc = self::clean_description(
178 isset( $campaign['short_desc'] ) ? $campaign['short_desc'] : ''
179 );
180 if ( ! empty( $campaign['date_created'] ) ) {
181 $post_date = (string) $campaign['date_created'];
182 }
183 }
184
185 // The campaign description is stored as the excerpt so post_content stays
186 // reserved for the seeded SureDonation page layout — imported campaigns
187 // then auto-seed the goal/stats/donate blocks on first publish (the
188 // description appears as a paragraph within that layout). GiveWP 4 keeps
189 // the body in `short_desc`; prefer a populated `long_desc` (legacy/v3),
190 // fall back to `short_desc`, then to the original form body.
191 $post_excerpt = $post_content;
192 if ( '' !== $long_desc ) {
193 $post_excerpt = $long_desc;
194 } elseif ( '' !== $short_desc ) {
195 $post_excerpt = $short_desc;
196 }
197 $post_content = '';
198
199 // GiveWP descriptions are rich HTML, but the excerpt is consumed as
200 // plain text (the seeded layout escapes it into a paragraph, the admin
201 // drawer edits it in a textarea) — strip the markup so it doesn't
202 // render as escaped entity soup. This also keeps unfiltered GiveWP
203 // markup from being stored under the importing admin's
204 // unfiltered_html context.
205 $post_excerpt = trim( wp_strip_all_tags( $post_excerpt ) );
206
207 if ( '' === $post_title ) {
208 $post_title = sprintf(
209 /* translators: %d: GiveWP form ID */
210 __( 'Imported campaign %d', 'suredonation' ),
211 $form_id
212 );
213 }
214
215 // The imported HTML was authored inside GiveWP (possibly by
216 // lower-privileged roles), so sanitize it regardless of the
217 // importing user's unfiltered_html capability — wp_insert_post()
218 // would otherwise store it raw for admins.
219 $post_content = wp_kses_post( $post_content );
220 $post_excerpt = wp_kses_post( $post_excerpt );
221
222 $campaign_id = wp_insert_post(
223 [
224 'post_title' => $post_title,
225 'post_content' => $post_content,
226 'post_excerpt' => $post_excerpt,
227 'post_status' => 'publish',
228 'post_type' => Campaign_Cpt::POST_TYPE,
229 'post_author' => $post_author,
230 'post_date' => $post_date,
231 'post_date_gmt' => $post_date_gmt,
232 ],
233 true
234 );
235
236 if ( is_wp_error( $campaign_id ) ) {
237 return $campaign_id;
238 }
239
240 $campaign_id = (int) $campaign_id;
241
242 // Provenance meta for duplicate detection + rollback scoping.
243 update_post_meta( $campaign_id, self::META_SOURCE_ID, $form_id );
244 if ( ! empty( $progress['import_id'] ) ) {
245 update_post_meta( $campaign_id, self::META_IMPORT_ID, (string) $progress['import_id'] );
246 }
247
248 // Reverse pointer on the GiveWP form so a rollback can clear it cleanly.
249 update_post_meta( $form_id, self::META_GIVEWP_MARKER, $campaign_id );
250
251 // Resolve goal amount + type. Prefer GiveWP 4 campaign row, fall
252 // back to legacy `_give_set_goal` post meta.
253 $goal_amount = 0;
254 $goal_type = 'raised_amount';
255 if ( is_array( $campaign ) ) {
256 if ( isset( $campaign['campaign_goal'] ) && is_numeric( $campaign['campaign_goal'] ) ) {
257 $goal_amount = (int) $campaign['campaign_goal'];
258 }
259 if ( ! empty( $campaign['goal_type'] ) ) {
260 $goal_type = $this->map_goal_type( (string) $campaign['goal_type'] );
261 }
262 } else {
263 $legacy_goal = get_post_meta( $form_id, '_give_set_goal', true );
264 if ( '' !== $legacy_goal && is_numeric( $legacy_goal ) ) {
265 $goal_amount = (int) $legacy_goal;
266 }
267 }
268
269 // Write to SureDonation's canonical _suredonation_campaign_meta
270 // JSON blob via Helper::update_campaign_meta. This is the key the
271 // All Campaigns UI reads (raised vs. goal display, status checks,
272 // fee-recovery toggle). Writing to a bare _campaign_goal post
273 // meta is a no-op as far as SureDonation is concerned.
274 Helper::update_campaign_meta(
275 $campaign_id,
276 [
277 'goal_amount' => $goal_amount,
278 'goal_type' => $goal_type,
279 'campaign_status' => 'active',
280 ]
281 );
282
283 // Preserve the rest of the GiveWP 4 campaign row in JSON meta so
284 // admin UI can surface dates, colors, hero image, etc. (we don't
285 // have first-class columns for all of them yet).
286 if ( is_array( $campaign ) ) {
287 $payload = [];
288 foreach ( [ 'goal_type', 'campaign_type', 'primary_color', 'secondary_color', 'start_date', 'end_date', 'campaign_logo', 'campaign_image', 'campaign_url', 'campaign_page_id', 'status' ] as $field ) {
289 if ( isset( $campaign[ $field ] ) && '' !== (string) $campaign[ $field ] ) {
290 $payload[ $field ] = is_numeric( $campaign[ $field ] )
291 ? (int) $campaign[ $field ]
292 : sanitize_text_field( (string) $campaign[ $field ] );
293 }
294 }
295 if ( ! empty( $payload ) ) {
296 update_post_meta( $campaign_id, '_suredonation_givewp_campaign', wp_json_encode( $payload ) );
297 }
298 }
299
300 // Default form auto-creation. Restored in response to user feedback —
301 // having no form is worse than a basic SureDonation template the
302 // admin can edit. The template doesn't carry GiveWP's donation
303 // levels / custom fields / branding (migrating GiveWP form
304 // structure into a SureDonation Gutenberg block tree is a separate
305 // piece of work) — admins should expect to revise it.
306 if ( ! Campaign_Cpt::get_default_form_id( $campaign_id ) ) {
307 $new_form_id = Donation_Form::create_default_form_for_campaign( $campaign_id, $post_title );
308 if ( $new_form_id ) {
309 update_post_meta( $campaign_id, Campaign_Cpt::META_DEFAULT_FORM_ID, $new_form_id );
310 }
311 }
312
313 return $campaign_id;
314 }
315
316 /**
317 * Map a GiveWP goal_type to a SureDonation goal_type.
318 *
319 * GiveWP supports: amount / donations / donors / amountFromSubscriptions.
320 * SureDonation supports: raised_amount / donation_count.
321 *
322 * @param string $give_goal_type GiveWP goal_type column value.
323 * @return string SureDonation goal_type enum value.
324 * @since 1.0.0
325 */
326 private function map_goal_type( $give_goal_type ) {
327 switch ( strtolower( $give_goal_type ) ) {
328 case 'donations':
329 case 'donors':
330 return 'donation_count';
331 case 'amount':
332 case 'amountfromsubscriptions':
333 default:
334 return 'raised_amount';
335 }
336 }
337
338 /**
339 * Normalise a GiveWP campaign description value before writing it
340 * to a SureDonation post field.
341 *
342 * On GiveWP 4 the `short_desc` column on `give_campaigns` holds the
343 * actual campaign body (rich-text block-editor output) while
344 * `long_desc` is typically empty. Empty fields are stored as the
345 * JSON-empty-array literal `"[]"` (and occasionally `"{}"`), which
346 * would land in `post_content` / `post_excerpt` verbatim if we
347 * passed it through. Treat such placeholders (along with
348 * whitespace-only and non-strings) as an empty value so the
349 * caller's fallback (e.g. the give_forms post_content) applies
350 * instead.
351 *
352 * @param mixed $value Raw column value.
353 * @return string Cleaned description, or empty string when the
354 * source held a placeholder.
355 * @since 1.0.0
356 */
357 private static function clean_description( $value ) {
358 if ( ! is_scalar( $value ) ) {
359 return '';
360 }
361 $trimmed = trim( (string) $value );
362 if ( '' === $trimmed || '[]' === $trimmed || '{}' === $trimmed ) {
363 return '';
364 }
365 return $trimmed;
366 }
367
368 /**
369 * Append an entry to the error log array, capping at 50 entries to keep the transient bounded.
370 *
371 * @param array $log Existing error log.
372 * @param int $source_id GiveWP source ID that failed.
373 * @param string $message Error message.
374 * @return array Updated log.
375 * @since 1.0.0
376 */
377 private function append_error_log( $log, $source_id, $message ) {
378 if ( ! is_array( $log ) ) {
379 $log = [];
380 }
381 $log[] = [
382 'source_id' => (int) $source_id,
383 'message' => (string) $message,
384 ];
385 if ( count( $log ) > 50 ) {
386 $log = array_slice( $log, -50 );
387 }
388 return $log;
389 }
390 }
391