From d97ffa59d4494dd32dc5b66b3495e500308c76a8 Mon Sep 17 00:00:00 2001 From: Lae <30905719+mlaetitia@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:38:13 +0000 Subject: [PATCH 1/6] Only save supporters from the OSAID Endorsement form, and never without a name, so other forms stop creating empty supporter posts. Co-Authored-By: Claude Opus 5.5 --- themes/osi/functions.php | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/themes/osi/functions.php b/themes/osi/functions.php index d9c61d7..ca35d2c 100755 --- a/themes/osi/functions.php +++ b/themes/osi/functions.php @@ -441,6 +441,12 @@ function osi_wpdc_comment_body( string $comment_body ) { } add_filter( 'wpdc_comment_body', 'osi_wpdc_comment_body', 10, 1 ); +/** + * Title of the form that collects endorsements, and of the Flamingo channel it feeds. + * Keyed on the title because form IDs differ between environments. + */ +const OSI_ENDORSEMENT_FORM_TITLE = 'OSAID Endorsement'; + /** * Process the supporter form submission. * @@ -448,7 +454,11 @@ function osi_wpdc_comment_body( string $comment_body ) { * * @return void */ -function osi_handle_support_form_submission( WPCF7_ContactForm $contact_form ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found +function osi_handle_support_form_submission( WPCF7_ContactForm $contact_form ): void { + if ( OSI_ENDORSEMENT_FORM_TITLE !== $contact_form->title() ) { + return; + } + $submission = WPCF7_Submission::get_instance(); if ( $submission ) { @@ -467,6 +477,10 @@ function osi_handle_support_form_submission( WPCF7_ContactForm $contact_form ): * @return void */ function osi_save_supporter_form_data_to_cpt( array $form_data ): void { + if ( '' === trim( (string) ( $form_data['your-name'] ?? '' ) ) ) { + return; + } + $post_id = wp_insert_post( array( 'post_title' => $form_data['your-name'], @@ -505,7 +519,7 @@ function osi_handle_supporter_form_flamingo_spam_status_change( string $new_stat if ( 'flamingo-spam' === $old_status && 'publish' === $new_status ) { $term_obj_list = get_the_terms( $post->ID, 'flamingo_inbound_channel' ); - if ( empty( $term_obj_list ) || is_wp_error( $term_obj_list ) || 'OSAID Endorsement' !== $term_obj_list[0]->name ) { + if ( empty( $term_obj_list ) || is_wp_error( $term_obj_list ) || OSI_ENDORSEMENT_FORM_TITLE !== $term_obj_list[0]->name ) { return; } @@ -516,9 +530,7 @@ function osi_handle_supporter_form_flamingo_spam_status_change( string $new_stat 'your-message' => get_post_meta( $post->ID, '_field_your-message', true ), ); - if ( ! empty( $form_data['your-name'] ) ) { - osi_save_supporter_form_data_to_cpt( $form_data ); - } + osi_save_supporter_form_data_to_cpt( $form_data ); } } add_action( 'transition_post_status', 'osi_handle_supporter_form_flamingo_spam_status_change', 10, 3 ); From c158947c102f7b74e81a23aa6fd6f2b945ca713d Mon Sep 17 00:00:00 2001 From: Lae <30905719+mlaetitia@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:57:10 +0000 Subject: [PATCH 2/6] Add a WP-CLI command that trashes or deletes the empty supporter posts other forms created, with a dry run, limits and a timestamped log. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + .../osi-supporter-cleanup.php | 172 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php diff --git a/.gitignore b/.gitignore index 1ede1ed..7612f6e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ mu-plugins/* !websub-compat.php !mu-plugins/osi-events-manager-tweaks !mu-plugins/osi-editor-tweaks +!mu-plugins/osi-supporter-cleanup themes/* !themes/osi diff --git a/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php b/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php new file mode 100644 index 0000000..26a4260 --- /dev/null +++ b/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php @@ -0,0 +1,172 @@ +] + * : Report what would be removed without removing it. Pass --dry-run=false to remove. + * --- + * default: 'true' + * --- + * + * [--force] + * : Delete permanently instead of moving to the trash. + * + * [--trashed] + * : Target empty supporter posts already in the trash. Use with --force to purge them. + * + * [--batch-size=] + * : Posts per batch. + * --- + * default: 100 + * --- + * + * [--limit=] + * : Stop after this many posts (0 = no limit). + * --- + * default: 0 + * --- + * + * [--sleep=] + * : Pause between batches. + * --- + * default: 2 + * --- + * + * [--yes] + * : Skip the confirmation prompt on a real run. + * + * ## EXAMPLES + * + * wp osi-supporter-cleanup + * wp osi-supporter-cleanup --limit=10 --dry-run=false + * wp osi-supporter-cleanup --dry-run=false --yes | tee supporter-cleanup.log + * wp osi-supporter-cleanup --trashed --force --dry-run=false + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * + * @return void + */ + public function __invoke( array $args, array $assoc_args ): void { + $dry_run = ! in_array( strtolower( (string) $assoc_args['dry-run'] ), array( 'false', '0', 'no', '' ), true ); + $force = ! empty( $assoc_args['force'] ); + $status = empty( $assoc_args['trashed'] ) ? 'pending' : 'trash'; + $batch_size = max( 1, absint( $assoc_args['batch-size'] ) ); + $limit = absint( $assoc_args['limit'] ); + $sleep = absint( $assoc_args['sleep'] ); + $verb = ( $dry_run ? 'would be ' : '' ) . ( $force ? 'deleted' : 'trashed' ); + + $this->log( sprintf( '%s %s posts.', $dry_run ? 'DRY RUN on' : ( $force ? 'DELETING' : 'TRASHING' ), $status ) ); + + if ( ! $dry_run ) { + WP_CLI::confirm( sprintf( '%s empty supporter posts%s?', $force ? 'Permanently delete' : 'Trash', $limit ? " (limit {$limit})" : '' ), $assoc_args ); + wp_defer_term_counting( true ); + } + + $limit = $limit ? $limit : PHP_INT_MAX; + $processed = 0; + $failed = 0; + $last_id = 0; + + while ( $processed < $limit ) { + $ids = $this->get_candidates( $status, $last_id, min( $batch_size, $limit - $processed ) ); + if ( empty( $ids ) ) { + break; + } + $last_id = end( $ids ); + + foreach ( $ids as $id ) { + if ( ! $dry_run && ! ( $force ? wp_delete_post( $id, true ) : wp_trash_post( $id ) ) ) { + ++$failed; + WP_CLI::warning( sprintf( 'Failed on %d.', $id ) ); + continue; + } + ++$processed; + } + + $this->log( sprintf( '%d %s so far, %s MB in use. IDs: %s', $processed, $verb, number_format( memory_get_usage() / MB_IN_BYTES, 1 ), implode( ',', $ids ) ) ); + + if ( wp_cache_supports( 'flush_runtime' ) ) { + wp_cache_flush_runtime(); + } + if ( $sleep ) { + sleep( $sleep ); + } + } + + if ( ! $dry_run ) { + wp_defer_term_counting( false ); + } + + if ( $failed ) { + $this->log( sprintf( 'Failed: %d', $failed ) ); + } + WP_CLI::success( sprintf( '%d empty supporter posts %s.', $processed, $verb ) ); + } + + /** + * Fetch the next batch of supporter IDs with no title and no name, organization, link or logo. + * The broken form handler only ever created pending posts, and gave none of them those fields. + * + * @param string $status Post status to search. + * @param integer $last_id Fetch IDs above this one. + * @param integer $size Batch size. + * + * @return int[] + */ + private function get_candidates( string $status, int $last_id, int $size ): array { + global $wpdb; + + $ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT p.ID FROM {$wpdb->posts} p + WHERE p.post_type = 'supporter' AND p.post_status = %s AND p.post_title = '' AND p.ID > %d + AND NOT EXISTS ( + SELECT 1 FROM {$wpdb->postmeta} m WHERE m.post_id = p.ID + AND m.meta_key IN ( 'name', 'organization', 'link', '_thumbnail_id' ) AND TRIM( m.meta_value ) NOT IN ( '', '0' ) + ) + ORDER BY p.ID ASC LIMIT %d", + $status, + $last_id, + $size + ) + ); + + if ( $wpdb->last_error ) { + WP_CLI::error( $wpdb->last_error ); + } + + return array_map( 'intval', $ids ); + } + + /** + * Log a line with a UTC timestamp, so a teed run doubles as a record. + * + * @param string $message Message. + * + * @return void + */ + private function log( string $message ): void { + WP_CLI::log( sprintf( '[%s UTC] %s', gmdate( 'Y-m-d H:i:s' ), $message ) ); + } +} + +WP_CLI::add_command( 'osi-supporter-cleanup', 'OSI_Supporter_Cleanup_Command' ); From 3c6a8a16f1e677d448e43243602d5c0d8a23ae04 Mon Sep 17 00:00:00 2001 From: Lae <30905719+mlaetitia@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:00:30 +0000 Subject: [PATCH 3/6] Match supporter posts from other forms by their missing endorsement type, so junk posts that carry a name are cleaned up too, and skip anything an admin created or edited. Co-Authored-By: Claude Opus 5.5 --- .../osi-supporter-cleanup.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php b/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php index 26a4260..5008b76 100644 --- a/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php +++ b/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php @@ -1,6 +1,6 @@ ] * : Posts per batch. @@ -77,7 +77,7 @@ public function __invoke( array $args, array $assoc_args ): void { $this->log( sprintf( '%s %s posts.', $dry_run ? 'DRY RUN on' : ( $force ? 'DELETING' : 'TRASHING' ), $status ) ); if ( ! $dry_run ) { - WP_CLI::confirm( sprintf( '%s empty supporter posts%s?', $force ? 'Permanently delete' : 'Trash', $limit ? " (limit {$limit})" : '' ), $assoc_args ); + WP_CLI::confirm( sprintf( '%s supporter posts from other forms%s?', $force ? 'Permanently delete' : 'Trash', $limit ? " (limit {$limit})" : '' ), $assoc_args ); wp_defer_term_counting( true ); } @@ -119,12 +119,12 @@ public function __invoke( array $args, array $assoc_args ): void { if ( $failed ) { $this->log( sprintf( 'Failed: %d', $failed ) ); } - WP_CLI::success( sprintf( '%d empty supporter posts %s.', $processed, $verb ) ); + WP_CLI::success( sprintf( '%d supporter posts from other forms %s.', $processed, $verb ) ); } /** - * Fetch the next batch of supporter IDs with no title and no name, organization, link or logo. - * The broken form handler only ever created pending posts, and gave none of them those fields. + * Fetch the next batch of anonymous, never-edited supporter IDs with no endorsement type, link or logo. + * The endorsement form always sends a type, so a submission without one came from another form. * * @param string $status Post status to search. * @param integer $last_id Fetch IDs above this one. @@ -138,10 +138,10 @@ private function get_candidates( string $status, int $last_id, int $size ): arra $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p - WHERE p.post_type = 'supporter' AND p.post_status = %s AND p.post_title = '' AND p.ID > %d + WHERE p.post_type = 'supporter' AND p.post_status = %s AND p.post_author = 0 AND p.ID > %d AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} m WHERE m.post_id = p.ID - AND m.meta_key IN ( 'name', 'organization', 'link', '_thumbnail_id' ) AND TRIM( m.meta_value ) NOT IN ( '', '0' ) + AND m.meta_key IN ( 'endorsement_type', 'link', '_thumbnail_id', '_edit_last' ) AND TRIM( m.meta_value ) NOT IN ( '', '0' ) ) ORDER BY p.ID ASC LIMIT %d", $status, From 33621c25c2174bdc5d2523f72fd4f5b346edd118 Mon Sep 17 00:00:00 2001 From: Lae <30905719+mlaetitia@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:05:14 +0000 Subject: [PATCH 4/6] Add --newest-first to the supporter cleanup so a test submission can be removed with --limit=1, and count failed posts toward the limit so a failure never moves on to another post. Co-Authored-By: Claude Opus 5.5 --- .../osi-supporter-cleanup.php | 60 +++++++++++++------ 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php b/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php index 5008b76..ad7e099 100644 --- a/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php +++ b/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php @@ -32,6 +32,9 @@ class OSI_Supporter_Cleanup_Command extends WP_CLI_Command { * [--trashed] * : Target matching supporter posts already in the trash. Use with --force to purge them. * + * [--newest-first] + * : Work from the highest ID down, e.g. to remove a test submission with --limit=1. + * * [--batch-size=] * : Posts per batch. * --- @@ -59,6 +62,7 @@ class OSI_Supporter_Cleanup_Command extends WP_CLI_Command { * wp osi-supporter-cleanup --limit=10 --dry-run=false * wp osi-supporter-cleanup --dry-run=false --yes | tee supporter-cleanup.log * wp osi-supporter-cleanup --trashed --force --dry-run=false + * wp osi-supporter-cleanup --newest-first --limit=1 --dry-run=false * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. @@ -69,6 +73,7 @@ public function __invoke( array $args, array $assoc_args ): void { $dry_run = ! in_array( strtolower( (string) $assoc_args['dry-run'] ), array( 'false', '0', 'no', '' ), true ); $force = ! empty( $assoc_args['force'] ); $status = empty( $assoc_args['trashed'] ) ? 'pending' : 'trash'; + $desc = ! empty( $assoc_args['newest-first'] ); $batch_size = max( 1, absint( $assoc_args['batch-size'] ) ); $limit = absint( $assoc_args['limit'] ); $sleep = absint( $assoc_args['sleep'] ); @@ -84,10 +89,10 @@ public function __invoke( array $args, array $assoc_args ): void { $limit = $limit ? $limit : PHP_INT_MAX; $processed = 0; $failed = 0; - $last_id = 0; + $last_id = $desc ? PHP_INT_MAX : 0; - while ( $processed < $limit ) { - $ids = $this->get_candidates( $status, $last_id, min( $batch_size, $limit - $processed ) ); + while ( $processed + $failed < $limit ) { + $ids = $this->get_candidates( $status, $last_id, min( $batch_size, $limit - $processed - $failed ), $desc ); if ( empty( $ids ) ) { break; } @@ -127,28 +132,47 @@ public function __invoke( array $args, array $assoc_args ): void { * The endorsement form always sends a type, so a submission without one came from another form. * * @param string $status Post status to search. - * @param integer $last_id Fetch IDs above this one. + * @param integer $last_id Fetch IDs past this one. * @param integer $size Batch size. + * @param boolean $desc Highest IDs first. * * @return int[] */ - private function get_candidates( string $status, int $last_id, int $size ): array { + private function get_candidates( string $status, int $last_id, int $size, bool $desc ): array { global $wpdb; - $ids = $wpdb->get_col( - $wpdb->prepare( - "SELECT p.ID FROM {$wpdb->posts} p - WHERE p.post_type = 'supporter' AND p.post_status = %s AND p.post_author = 0 AND p.ID > %d - AND NOT EXISTS ( - SELECT 1 FROM {$wpdb->postmeta} m WHERE m.post_id = p.ID - AND m.meta_key IN ( 'endorsement_type', 'link', '_thumbnail_id', '_edit_last' ) AND TRIM( m.meta_value ) NOT IN ( '', '0' ) + // Two literal queries, because ORDER BY can't take a placeholder. + if ( $desc ) { + $ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT p.ID FROM {$wpdb->posts} p + WHERE p.post_type = 'supporter' AND p.post_status = %s AND p.post_author = 0 AND p.ID < %d + AND NOT EXISTS ( + SELECT 1 FROM {$wpdb->postmeta} m WHERE m.post_id = p.ID + AND m.meta_key IN ( 'endorsement_type', 'link', '_thumbnail_id', '_edit_last' ) AND TRIM( m.meta_value ) NOT IN ( '', '0' ) + ) + ORDER BY p.ID DESC LIMIT %d", + $status, + $last_id, + $size + ) + ); + } else { + $ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT p.ID FROM {$wpdb->posts} p + WHERE p.post_type = 'supporter' AND p.post_status = %s AND p.post_author = 0 AND p.ID > %d + AND NOT EXISTS ( + SELECT 1 FROM {$wpdb->postmeta} m WHERE m.post_id = p.ID + AND m.meta_key IN ( 'endorsement_type', 'link', '_thumbnail_id', '_edit_last' ) AND TRIM( m.meta_value ) NOT IN ( '', '0' ) + ) + ORDER BY p.ID ASC LIMIT %d", + $status, + $last_id, + $size ) - ORDER BY p.ID ASC LIMIT %d", - $status, - $last_id, - $size - ) - ); + ); + } if ( $wpdb->last_error ) { WP_CLI::error( $wpdb->last_error ); From e9f23c9279f99d29bf0ce1a738097fa4046ae808 Mon Sep 17 00:00:00 2001 From: Lae <30905719+mlaetitia@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:29:02 +0000 Subject: [PATCH 5/6] Show the endorsement type to admins as a radio field, and save it as a single value so new endorsements match. Co-Authored-By: Claude Opus 5.5 --- .../acf-fields/group_672accc71e0c4.json | 30 ++++++++++++++++++- themes/osi/functions.php | 18 +++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/plugins/osi-features/acf-fields/group_672accc71e0c4.json b/plugins/osi-features/acf-fields/group_672accc71e0c4.json index 2ffed88..2b834c1 100644 --- a/plugins/osi-features/acf-fields/group_672accc71e0c4.json +++ b/plugins/osi-features/acf-fields/group_672accc71e0c4.json @@ -46,6 +46,34 @@ "prepend": "", "append": "" }, + { + "key": "field_672accd44e671", + "label": "Endorsement type", + "name": "endorsement_type", + "aria-label": "", + "type": "radio", + "instructions": "From the endorsement form. A supporter without one did not come from that form.", + "required": 0, + "conditional_logic": 0, + "wrapper": { + "width": "", + "class": "", + "id": "" + }, + "relevanssi_exclude": 0, + "choices": { + "Individual": "Individual", + "Institutional": "Institutional", + "Both": "Both" + }, + "default_value": "", + "return_format": "value", + "allow_null": 1, + "other_choice": 0, + "allow_in_bindings": 0, + "layout": "horizontal", + "save_other_choice": 0 + }, { "key": "field_672ad07b77def", "label": "Link", @@ -130,5 +158,5 @@ "active": true, "description": "", "show_in_rest": 0, - "modified": 1733133647 + "modified": 1793810000 } diff --git a/themes/osi/functions.php b/themes/osi/functions.php index ca35d2c..95e64c9 100755 --- a/themes/osi/functions.php +++ b/themes/osi/functions.php @@ -496,12 +496,24 @@ function osi_save_supporter_form_data_to_cpt( array $form_data ): void { if ( $post_id ) { update_field( 'name', $form_data['your-name'], $post_id ); - update_field( 'organization', $form_data['your-org'], $post_id ); - update_field( 'endorsement_type', $form_data['your-endorsement'], $post_id ); - update_field( 'quote', $form_data['your-message'], $post_id ); + update_field( 'organization', $form_data['your-org'] ?? '', $post_id ); + update_field( 'endorsement_type', osi_single_endorsement_type( $form_data['your-endorsement'] ?? '' ), $post_id ); + update_field( 'quote', $form_data['your-message'] ?? '', $post_id ); } } +/** + * CF7 posts a radio as a one-item array; the Endorsement type field is a radio, so keep the single value. + * + * @param mixed $value Stored or submitted endorsement type. + * + * @return mixed + */ +function osi_single_endorsement_type( mixed $value ): mixed { + return is_array( $value ) ? (string) reset( $value ) : $value; +} +add_filter( 'acf/load_value/name=endorsement_type', 'osi_single_endorsement_type' ); + /** * Handle the supporter form spam status change. * From b3225bdc2d16a58f013f03f6ae008716d9ebf891 Mon Sep 17 00:00:00 2001 From: Lae <30905719+mlaetitia@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:30:09 +0000 Subject: [PATCH 6/6] Remove an inline comment from the supporter cleanup query. Co-Authored-By: Claude Opus 5.5 --- mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php | 1 - 1 file changed, 1 deletion(-) diff --git a/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php b/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php index ad7e099..5ca2221 100644 --- a/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php +++ b/mu-plugins/osi-supporter-cleanup/osi-supporter-cleanup.php @@ -141,7 +141,6 @@ public function __invoke( array $args, array $assoc_args ): void { private function get_candidates( string $status, int $last_id, int $size, bool $desc ): array { global $wpdb; - // Two literal queries, because ORDER BY can't take a placeholder. if ( $desc ) { $ids = $wpdb->get_col( $wpdb->prepare(