Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/progress-planner/classes/admin/class-page-settings.php
+++ b/progress-planner/classes/admin/class-page-settings.php
@@ -15,42 +15,6 @@
class Page_Settings {
/**
- * Constructor.
- */
- public function __construct() {
- // Add the admin menu page.
- add_action( 'admin_menu', [ $this, 'add_admin_menu_page' ] );
-
- // Add AJAX hooks to save options.
- add_action( 'wp_ajax_prpl_settings_form', [ $this, 'store_settings_form_options' ] );
- }
-
- /**
- * Add admin-menu page, as a submenu in the progress-planner menu.
- *
- * @return void
- */
- public function add_admin_menu_page() {
- add_submenu_page(
- 'progress-planner',
- esc_html__( 'Settings', 'progress-planner' ),
- esc_html__( 'Settings', 'progress-planner' ),
- 'manage_options',
- 'progress-planner-settings',
- [ $this, 'add_admin_page_content' ]
- );
- }
-
- /**
- * Add content to the admin page of the free plugin.
- *
- * @return void
- */
- public function add_admin_page_content() {
- require_once PROGRESS_PLANNER_DIR . '/views/admin-page-settings.php';
- }
-
- /**
* Get an array of settings.
*
* @return array
@@ -58,27 +22,28 @@
public function get_settings() {
$settings = [];
foreach ( progress_planner()->get_page_types()->get_page_types() as $page_type ) {
- if ( ! $this->should_show_setting( $page_type['slug'] ) ) {
+ $slug = (string) $page_type['slug']; // @phpstan-ignore offsetAccess.invalidOffset
+ if ( ! $this->should_show_setting( $slug ) ) {
continue;
}
- $settings[ $page_type['slug'] ] = [
- 'id' => $page_type['slug'],
+ $settings[ $slug ] = [
+ 'id' => $slug,
'value' => '_no_page_needed',
'isset' => 'no',
- 'title' => $page_type['title'],
- 'description' => $page_type['description'] ?? '',
+ 'title' => $page_type['title'], // @phpstan-ignore offsetAccess.invalidOffset
+ 'description' => $page_type['description'] ?? '', // @phpstan-ignore offsetAccess.invalidOffset
'type' => 'page-select',
- 'page' => $page_type['slug'],
+ 'page' => $slug,
];
- if ( progress_planner()->get_page_types()->is_page_needed( $page_type['slug'] ) ) {
- $type_pages = progress_planner()->get_page_types()->get_posts_by_type( 'any', $page_type['slug'] );
+ if ( progress_planner()->get_page_types()->is_page_needed( $slug ) ) {
+ $type_pages = progress_planner()->get_page_types()->get_posts_by_type( 'any', $slug );
if ( empty( $type_pages ) ) {
- $settings[ $page_type['slug'] ]['value'] = progress_planner()->get_page_types()->get_default_page_id_by_type( $page_type['slug'] );
+ $settings[ $slug ]['value'] = progress_planner()->get_page_types()->get_default_page_id_by_type( $slug );
} else {
- $settings[ $page_type['slug'] ]['value'] = $type_pages[0]->ID;
- $settings[ $page_type['slug'] ]['isset'] = 'yes';
+ $settings[ $slug ]['value'] = $type_pages[0]->ID;
+ $settings[ $slug ]['isset'] = 'yes';
// If there is more than one page, we need to check if the page has a parent with the same page-type assigned.
if ( 1 < count( $type_pages ) ) {
@@ -89,7 +54,7 @@
foreach ( $type_pages as $type_page ) {
$parent = get_post_field( 'post_parent', $type_page->ID );
if ( $parent && in_array( (int) $parent, $type_pages_ids, true ) ) {
- $settings[ $page_type['slug'] ]['value'] = $parent;
+ $settings[ $slug ]['value'] = $parent;
break;
}
}
@@ -123,95 +88,76 @@
}
/**
- * Store the settings form options.
+ * Set the page value.
+ *
+ * @param array $pages The pages.
*
* @return void
*/
- public function store_settings_form_options() {
-
- if ( ! current_user_can( 'manage_options' ) ) {
- wp_send_json_error( [ 'message' => esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] );
- }
+ public function set_page_values( $pages ) {
- // Use check_ajax_referer instead of check_admin_referer for AJAX handlers.
- // check_admin_referer is designed for form submissions, not AJAX requests.
- if ( ! check_ajax_referer( 'progress_planner', 'nonce', false ) ) {
- wp_send_json_error( [ 'message' => esc_html__( 'Invalid nonce.', 'progress-planner' ) ] );
+ if ( empty( $pages ) ) {
+ return;
}
- if ( isset( $_POST['pages'] ) ) {
- // Sanitize the pages array at point of reception.
- $pages = map_deep( wp_unslash( $_POST['pages'] ), 'sanitize_text_field' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-
- foreach ( $pages as $type => $page_args ) {
- $need_page = isset( $page_args['have_page'] ) ? $page_args['have_page'] : '';
-
- progress_planner()->get_page_types()->set_no_page_needed(
- $type,
- 'not-applicable' === $need_page
- );
-
- // Remove the post-meta from the existing posts.
- $existing_posts = progress_planner()->get_page_types()->get_posts_by_type( 'any', $type );
- foreach ( $existing_posts as $post ) {
- if ( $post->ID === (int) $page_args['id'] && 'no' !== $page_args['have_page'] ) {
- continue;
- }
+ foreach ( $pages as $type => $page_args ) {
+ $need_page = isset( $page_args['have_page'] ) ? $page_args['have_page'] : '';
- // Get the term-ID for the type.
- $term = get_term_by( 'slug', $type, Page_Types::TAXONOMY_NAME );
- if ( ! $term instanceof WP_Term ) {
- continue;
- }
+ progress_planner()->get_page_types()->set_no_page_needed(
+ $type,
+ 'not-applicable' === $need_page
+ );
- // Remove the assigned terms from the `progress_planner_page_types` taxonomy.
- wp_remove_object_terms( $post->ID, $term->term_id, Page_Types::TAXONOMY_NAME );
+ // Remove the post-meta from the existing posts.
+ $existing_posts = progress_planner()->get_page_types()->get_posts_by_type( 'any', $type );
+ foreach ( $existing_posts as $post ) {
+ if ( $post->ID === (int) $page_args['id'] && 'no' !== $page_args['have_page'] ) {
+ continue;
}
- // Skip if the ID is not set.
- if ( ! isset( $page_args['id'] ) || 1 > (int) $page_args['id'] ) {
+ // Get the term-ID for the type.
+ $term = get_term_by( 'slug', $type, Page_Types::TAXONOMY_NAME );
+ if ( ! $term instanceof WP_Term ) {
continue;
}
- if ( 'no' !== $page_args['have_page'] ) {
- // Add the term to the `progress_planner_page_types` taxonomy.
- progress_planner()->get_page_types()->set_page_type_by_id( (int) $page_args['id'], $type );
- }
+ // Remove the assigned terms from the `progress_planner_page_types` taxonomy.
+ wp_remove_object_terms( $post->ID, $term->term_id, Page_Types::TAXONOMY_NAME );
}
- }
- $this->save_settings();
- $this->save_post_types();
-
- do_action( 'progress_planner_settings_form_options_stored' );
+ // Skip if the ID is not set.
+ if ( ! isset( $page_args['id'] ) || 1 > (int) $page_args['id'] ) {
+ continue;
+ }
- wp_send_json_success( esc_html__( 'Options stored successfully', 'progress-planner' ) );
+ if ( 'no' !== $page_args['have_page'] ) {
+ // Add the term to the `progress_planner_page_types` taxonomy.
+ progress_planner()->get_page_types()->set_page_type_by_id( (int) $page_args['id'], $type );
+ }
+ }
}
/**
- * Save the settings.
+ * Save the redirect on login setting.
+ *
+ * @param bool $redirect_on_login Whether to redirect on login.
*
* @return void
*/
- public function save_settings() {
- // Nonce is already checked in store_settings_form_options() which calls this method.
- $redirect_on_login = isset( $_POST['prpl-redirect-on-login'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
- ? sanitize_text_field( wp_unslash( $_POST['prpl-redirect-on-login'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
- : false;
-
- update_user_meta( get_current_user_id(), 'prpl_redirect_on_login', (bool) $redirect_on_login );
+ public function save_redirect_on_login( $redirect_on_login = false ) {
+ update_user_meta( get_current_user_id(), 'prpl_redirect_on_login', $redirect_on_login );
}
/**
* Save the post types.
*
+ * @param array $post_types The post types.
+ *
* @return void
*/
- public function save_post_types() {
- // Nonce is already checked in store_settings_form_options() which calls this method.
- $include_post_types = isset( $_POST['prpl-post-types-include'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
- ? array_map( 'sanitize_text_field', wp_unslash( $_POST['prpl-post-types-include'] ) ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
- // If no post types are selected, use the default post types (post and page can be deregistered).
+ public function save_post_types( $post_types = [] ) {
+ $include_post_types = ! empty( $post_types )
+ ? $post_types
: array_intersect( [ 'post', 'page' ], progress_planner()->get_settings()->get_public_post_types() );
progress_planner()->get_settings()->set( 'include_post_types', $include_post_types );
--- a/progress-planner/classes/admin/widgets/class-activity-scores.php
+++ b/progress-planner/classes/admin/widgets/class-activity-scores.php
@@ -98,7 +98,8 @@
$items = $this->get_checklist();
$results = [];
foreach ( $items as $item ) {
- $results[ $item['label'] ] = $item['callback']();
+ $label = (string) $item['label']; // @phpstan-ignore offsetAccess.invalidOffset
+ $results[ $label ] = $item['callback'](); // @phpstan-ignore offsetAccess.invalidOffset
}
return $results;
}
--- a/progress-planner/classes/class-base.php
+++ b/progress-planner/classes/class-base.php
@@ -86,10 +86,12 @@
*/
public function init() {
if ( ! function_exists( 'current_user_can' ) ) {
- require_once ABSPATH . 'wp-includes/capabilities.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-includes/capabilities.php';
}
if ( ! function_exists( 'wp_get_current_user' ) ) {
- require_once ABSPATH . 'wp-includes/pluggable.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-includes/pluggable.php';
}
if ( defined( 'IS_PLAYGROUND_PREVIEW' ) && constant( 'IS_PLAYGROUND_PREVIEW' ) === true ) {
@@ -380,7 +382,8 @@
// Otherwise, use the plugin header.
if ( ! function_exists( 'get_file_data' ) ) {
- require_once ABSPATH . 'wp-includes/functions.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-includes/functions.php';
}
if ( ! self::$plugin_version ) {
--- a/progress-planner/classes/class-suggested-tasks.php
+++ b/progress-planner/classes/class-suggested-tasks.php
@@ -67,6 +67,9 @@
// Filter the REST API response.
add_filter( 'rest_prepare_prpl_recommendations', [ $this, 'rest_prepare_recommendation' ], 10, 2 );
+ // Sanitize the recommendation title on insert/update via the REST API, to prevent stored XSS.
+ add_filter( 'rest_pre_insert_prpl_recommendations', [ $this, 'rest_sanitize_recommendation' ], 10, 2 );
+
add_filter( 'wp_trash_post_days', [ $this, 'change_trashed_posts_lifetime' ], 10, 2 );
}
@@ -479,6 +482,7 @@
// Handle sorting parameters.
if ( isset( $request['filter']['orderby'] ) ) {
+ // @phpstan-ignore-next-line argument.templateType
$args['orderby'] = sanitize_sql_orderby( $request['filter']['orderby'] );
}
if ( isset( $request['filter']['order'] ) ) {
@@ -491,6 +495,28 @@
}
/**
+ * Sanitize a recommendation before it is inserted or updated via the REST API.
+ *
+ * Recommendation titles are plain text (they are rendered unescaped in JS
+ * templates such as views/js-templates/suggested-task.html), so we strip any
+ * HTML tags here to prevent stored XSS. This runs regardless of the user's
+ * `unfiltered_html` capability, which WordPress would otherwise honor for the
+ * post title.
+ *
+ * @param stdClass $prepared_post An object representing a single post prepared for inserting or updating the database.
+ * @param WP_REST_Request $request The request object.
+ *
+ * @return stdClass The sanitized post object.
+ */
+ public function rest_sanitize_recommendation( $prepared_post, $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ if ( isset( $prepared_post->post_title ) ) {
+ $prepared_post->post_title = sanitize_text_field( wp_strip_all_tags( $prepared_post->post_title ) );
+ }
+
+ return $prepared_post;
+ }
+
+ /**
* Filter the REST API response.
*
* @param WP_REST_Response $response The response.
--- a/progress-planner/classes/suggested-tasks/class-task.php
+++ b/progress-planner/classes/suggested-tasks/class-task.php
@@ -183,12 +183,14 @@
// Make sure WP_REST_Posts_Controller is loaded.
if ( ! class_exists( 'WP_REST_Posts_Controller' ) ) {
- require_once ABSPATH . 'wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php';
}
// Make sure WP_REST_Request is loaded.
if ( ! class_exists( 'WP_REST_Request' ) ) {
- require_once ABSPATH . 'wp-includes/rest-api/class-wp-rest-request.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-includes/rest-api/class-wp-rest-request.php';
}
// Use the appropriate controller for the post type.
--- a/progress-planner/classes/suggested-tasks/data-collector/class-inactive-plugins.php
+++ b/progress-planner/classes/suggested-tasks/data-collector/class-inactive-plugins.php
@@ -47,7 +47,8 @@
*/
protected function calculate_data() {
if ( ! function_exists( 'get_plugins' ) ) {
- require_once ABSPATH . 'wp-admin/includes/plugin.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
// Clear the plugins cache, so get_plugins() returns the latest plugins.
--- a/progress-planner/classes/suggested-tasks/providers/class-core-update.php
+++ b/progress-planner/classes/suggested-tasks/providers/class-core-update.php
@@ -107,7 +107,8 @@
public function should_add_task() {
// Without this wp_get_update_data() might not return correct data for the core updates (depending on the timing).
if ( ! function_exists( 'get_core_updates' ) ) {
- require_once ABSPATH . 'wp-admin/includes/update.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-admin/includes/update.php';
}
// For wp_get_update_data() to return correct data it needs to be called after the 'admin_init' action (with priority 10).
--- a/progress-planner/classes/suggested-tasks/providers/class-fewer-tags.php
+++ b/progress-planner/classes/suggested-tasks/providers/class-fewer-tags.php
@@ -154,7 +154,8 @@
protected function is_plugin_active() {
if ( null === $this->is_plugin_active ) {
if ( ! function_exists( 'get_plugins' ) ) {
- require_once ABSPATH . 'wp-admin/includes/plugin.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugins = get_plugins();
--- a/progress-planner/classes/suggested-tasks/providers/class-select-locale.php
+++ b/progress-planner/classes/suggested-tasks/providers/class-select-locale.php
@@ -205,7 +205,8 @@
public function print_popover_form_contents() {
if ( ! function_exists( 'wp_get_available_translations' ) ) {
- require_once ABSPATH . 'wp-admin/includes/translation-install.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-admin/includes/translation-install.php';
}
$languages = get_available_languages();
@@ -277,7 +278,8 @@
// Handle translation installation.
if ( current_user_can( 'install_languages' ) ) {
- require_once ABSPATH . 'wp-admin/includes/translation-install.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-admin/includes/translation-install.php';
if ( wp_can_install_language_pack() ) {
$language = wp_download_language_pack( $language_for_update );
--- a/progress-planner/classes/ui/class-chart.php
+++ b/progress-planner/classes/ui/class-chart.php
@@ -15,8 +15,11 @@
/**
* Build a chart for the stats.
*
- * @param array $args The arguments for the chart.
- * See `get_chart_data` for the available parameters.
+ * @param array $args {
+ * The arguments for the chart. See `get_chart_data` for all available parameters.
+ *
+ * @type string $type Chart type (e.g., 'line', 'bar').
+ * }
*
* @return void
*/
@@ -28,23 +31,49 @@
/**
* Get data for the chart.
*
- * @param array $args The arguments for the chart.
- * ['items_callback'] The callback to get items.
- * ['filter_results'] The callback to filter the results. Leave empty/null to skip filtering.
- * ['dates_params'] The dates parameters for the query.
- * ['start_date'] The start date for the chart.
- * ['end_date'] The end date for the chart.
- * ['frequency'] The frequency for the chart nodes.
- * ['format'] The format for the label.
+ * Normalized charts:
+ * When $args['normalized'] is true, the chart implements a "decay" algorithm that carries
+ * forward previous period's activities into the current period with decaying values.
+ * This creates a rolling momentum effect where past activities continue to contribute
+ * to current scores, gradually diminishing over time.
+ *
+ * Example: If a user published 10 posts in January, the normalized chart for February
+ * will include both February's new posts plus a decayed value from January's posts.
+ * This encourages consistent activity by showing how past work continues to have impact.
+ *
+ * @param array $args {
+ * The arguments for the chart.
+ *
+ * @type callable $items_callback Callback to fetch items for a date range.
+ * Signature: function( DateTime $start_date, DateTime $end_date ): array
+ * @type callable|null $filter_results Optional callback to filter results after fetching.
+ * Signature: function( array $activities ): array
+ * @type array $dates_params {
+ * Date range and frequency parameters.
+ *
+ * @type DateTime $start_date The start date for the chart.
+ * @type DateTime $end_date The end date for the chart.
+ * @type string $frequency The frequency for chart nodes (e.g., 'day', 'week', 'month').
+ * @type string $format The label format (e.g., 'Y-m-d', 'M j').
+ * }
+ * @type bool $normalized Whether to use normalized scoring with decay from previous periods.
+ * Default false.
+ * @type callable $color Callback to determine bar/line color.
+ * Signature: function(): string (hex color code)
+ * @type callable $count_callback Callback to calculate score from activities.
+ * Signature: function( array $activities, DateTime|null $date ): int|float
+ * @type int|null $max Optional maximum value for chart scaling.
+ * @type string $type Chart type: 'line' or 'bar'. Default 'line'.
+ * @type array $return_data Which data fields to return in output.
+ * Default ['label', 'score', 'color'].
+ * }
*
- * @return array
+ * @return array Array of chart data points, each containing requested fields (label, score, color, etc).
*/
public function get_chart_data( $args = [] ) {
$activities = [];
- /*
- * Set default values for the arguments.
- */
+ // Set default values for the arguments.
$args = wp_parse_args(
$args,
[
@@ -61,7 +90,7 @@
]
);
- // Get the periods for the chart.
+ // Get the periods for the chart (e.g., months, weeks, days based on frequency).
$periods = progress_planner()->get_utils__date()->get_periods(
$args['dates_params']['start_date'],
$args['dates_params']['end_date'],
@@ -69,15 +98,25 @@
);
/*
- * "Normalized" charts decay the score of previous months activities,
- * and add them to the current month score.
- * This means that for "normalized" charts, we need to get activities
- * for the month prior to the first period.
+ * For "normalized" charts, implement a decay algorithm:
+ * - Previous period's activities "decay" and carry forward into current period
+ * - This creates momentum: past productivity continues to boost current scores
+ * - We need to fetch activities from the month BEFORE the chart starts
+ * - These previous activities will be added (with decay) to the first period's score
+ *
+ * Example: For a chart starting Feb 1, fetch Jan 1-31 activities to contribute
+ * to February's normalized score.
*/
$previous_period_activities = [];
if ( $args['normalized'] ) {
- $previous_month_start = ( clone $periods[0]['start_date'] )->modify( '-1 month' );
- $previous_month_end = ( clone $periods[0]['start_date'] )->modify( '-1 day' );
+ /**
+ * The start date of the first period.
+ *
+ * @var DateTime $first_period_start
+ */
+ $first_period_start = $periods[0]['start_date'];
+ $previous_month_start = ( clone $first_period_start )->modify( '-1 month' );
+ $previous_month_end = ( clone $first_period_start )->modify( '-1 day' );
$previous_period_activities = $args['items_callback']( $previous_month_start, $previous_month_end );
if ( $args['filter_results'] ) {
$activities = $args['filter_results']( $activities );
@@ -92,7 +131,8 @@
$previous_period_activities = $period_data['previous_period_activities'];
$period_data_filtered = [];
foreach ( $args['return_data'] as $key ) {
- $period_data_filtered[ $key ] = $period_data[ $key ];
+ $key_string = (string) $key; // @phpstan-ignore offsetAccess.invalidOffset
+ $period_data_filtered[ $key_string ] = $period_data[ $key_string ]; // @phpstan-ignore offsetAccess.invalidOffset
}
$data[] = $period_data_filtered;
}
@@ -101,30 +141,55 @@
}
/**
- * Get the data for a period.
+ * Get the data for a single period in the chart.
*
- * @param array $period The period.
- * @param array $args The arguments for the chart.
- * @param array $previous_period_activities The activities for the previous month.
- *
- * @return array
+ * For normalized charts, this implements the decay algorithm:
+ * 1. Calculate score from current period's activities (normal scoring)
+ * 2. Add decayed score from previous period's activities (normalized bonus)
+ * 3. Save current activities to decay into next period
+ *
+ * The decay is handled by the count_callback, which typically reduces scores
+ * based on how old the activities are relative to the current period.
+ *
+ * @param array $period {
+ * The time period being processed.
+ *
+ * @type DateTime $start_date Period start date.
+ * @type DateTime $end_date Period end date.
+ * @type string $label Human-readable label for this period.
+ * }
+ * @param array $args The chart arguments (see get_chart_data).
+ * @param array $previous_period_activities Activities from the previous period to apply decay to.
+ *
+ * @return array {
+ * Period data with score and metadata.
+ *
+ * @type string $label Period label (e.g., "Jan 2025").
+ * @type int|float $score Calculated score for this period.
+ * @type string $color Color for this data point.
+ * @type array $previous_period_activities Activities to carry forward to next period.
+ * }
*/
public function get_period_data( $period, $args, $previous_period_activities ) {
- // Get the activities for the period.
+ // Get the activities for the current period.
$activities = $args['items_callback']( $period['start_date'], $period['end_date'] );
- // Filter the results if a callback is provided.
+
+ // Apply optional filtering callback.
if ( $args['filter_results'] ) {
$activities = $args['filter_results']( $activities );
}
- // Calculate the score for the period.
+ // Calculate the base score from current period's activities.
$period_score = $args['count_callback']( $activities, $period['start_date'] );
- // If this is a "normalized" chart, we need to calculate the score for the previous month activities.
+ // For normalized charts, apply decay algorithm.
if ( $args['normalized'] ) {
- // Add the previous month activities to the current month score.
+ // Add decayed score from previous period's activities to current score.
+ // The count_callback determines the decay rate based on activity age.
$period_score += $args['count_callback']( $previous_period_activities, $period['start_date'] );
- // Update the previous month activities for the next iteration of the loop.
+
+ // Save current activities to decay into the next period.
+ // This creates a rolling momentum effect across time periods.
$previous_period_activities = $activities;
}
--- a/progress-planner/classes/update/class-update-140.php
+++ b/progress-planner/classes/update/class-update-140.php
@@ -41,10 +41,12 @@
// This is to ensure that we don't lose any tasks, and at the same time we don't have duplicate tasks.
$tasks = [];
foreach ( $new_tasks as $new_task ) {
- $tasks[ isset( $new_task['task_id'] ) ? $new_task['task_id'] : md5( maybe_serialize( $new_task ) ) ] = $new_task;
+ $key = isset( $new_task['task_id'] ) ? (string) $new_task['task_id'] : md5( maybe_serialize( $new_task ) ); // @phpstan-ignore offsetAccess.invalidOffset
+ $tasks[ $key ] = $new_task;
}
foreach ( $old_tasks as $old_task ) {
- $tasks[ isset( $old_task['task_id'] ) ? $old_task['task_id'] : md5( maybe_serialize( $old_task ) ) ] = $old_task;
+ $key = isset( $old_task['task_id'] ) ? (string) $old_task['task_id'] : md5( maybe_serialize( $old_task ) ); // @phpstan-ignore offsetAccess.invalidOffset
+ $tasks[ $key ] = $old_task;
}
// Set the tasks option.
--- a/progress-planner/classes/update/class-update-191.php
+++ b/progress-planner/classes/update/class-update-191.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * Update class for version 1.9.1.
+ *
+ * @package Progress_Planner
+ */
+
+namespace Progress_PlannerUpdate;
+
+/**
+ * Update class for version 1.9.1.
+ *
+ * @package Progress_Planner
+ */
+class Update_191 {
+
+ const VERSION = '1.9.1';
+
+ /**
+ * Run the update.
+ *
+ * @return void
+ */
+ public function run() {
+ $this->sanitize_recommendation_titles();
+ }
+
+ /**
+ * Strip HTML tags from existing recommendation titles.
+ *
+ * Titles are now sanitized on write, but rows stored before that may still
+ * contain markup. This cleans them up.
+ *
+ * @return void
+ */
+ private function sanitize_recommendation_titles() {
+ $db = progress_planner()->get_suggested_tasks_db();
+
+ // get() defaults to all relevant statuses (publish, trash, draft, future, pending).
+ $recommendations = $db->get();
+
+ foreach ( $recommendations as $recommendation ) {
+ $sanitized = sanitize_text_field( wp_strip_all_tags( $recommendation->post_title ) );
+
+ // Nothing to do if stripping didn't change the title.
+ if ( $sanitized === $recommendation->post_title ) {
+ continue;
+ }
+
+ // A title that was pure markup strips to an empty string. The plugin
+ // never stores title-less recommendations (Suggested_Tasks_DB::add()
+ // rejects them), so such a row is junk - delete it. This also avoids
+ // WordPress's empty-content guard, which would otherwise reject an
+ // update that leaves the title empty and leave the markup in place.
+ if ( '' === $sanitized ) {
+ $db->delete_recommendation( (int) $recommendation->ID );
+ continue;
+ }
+
+ $db->update_recommendation( $recommendation->ID, [ 'post_title' => $sanitized ] );
+ }
+ }
+}
--- a/progress-planner/classes/utils/class-color-customizer.php
+++ b/progress-planner/classes/utils/class-color-customizer.php
@@ -130,7 +130,7 @@
$post_value = isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
$color_value = sanitize_text_field( wp_unslash( $post_value ) );
if ( ! empty( $color_value ) ) {
- $colors[ $variable ] = $color_value;
+ $colors[ $variable ] = $color_value; // @phpstan-ignore offsetAccess.invalidOffset
}
}
}
--- a/progress-planner/classes/utils/class-date.php
+++ b/progress-planner/classes/utils/class-date.php
@@ -18,10 +18,7 @@
* @param DateTime $start_date The start date.
* @param DateTime $end_date The end date.
*
- * @return array [
- * 'start_date' => DateTime,
- * 'end_date' => DateTime,
- * ].
+ * @return array <array<string, DateTime>>
*/
public function get_range( $start_date, $end_date ) {
$dates = iterator_to_array( new DatePeriod( $start_date, new DateInterval( 'P1D' ), $end_date ), false );
@@ -38,7 +35,7 @@
* @param DateTime $end_date The end date.
* @param string $frequency The frequency. Can be 'daily', 'weekly', 'monthly'.
*
- * @return array
+ * @return array <array{start_date: DateTime, end_date: DateTime}>
*/
public function get_periods( $start_date, $end_date, $frequency ) {
$end_date->modify( '+1 day' );
@@ -71,8 +68,15 @@
if ( empty( $date_ranges ) ) {
return [];
}
- if ( $end_date->format( 'z' ) !== end( $date_ranges )['end_date']->format( 'z' ) ) {
- $final_end = clone end( $date_ranges )['end_date'];
+ $last_range = end( $date_ranges );
+ /**
+ * The end date of the last range.
+ *
+ * @var DateTime $last_end_date
+ */
+ $last_end_date = $last_range['end_date'];
+ if ( $end_date->format( 'z' ) !== $last_end_date->format( 'z' ) ) {
+ $final_end = clone $last_end_date;
$date_ranges[] = $this->get_range( $final_end->modify( '+1 day' ), $end_date );
}
--- a/progress-planner/classes/utils/class-debug-tools.php
+++ b/progress-planner/classes/utils/class-debug-tools.php
@@ -273,9 +273,11 @@
$admin_bar->add_node(
[
- 'id' => 'prpl-suggested-' . $key . '-' . $title,
+ // Use the post ID (not the title) for the node ID, and escape the
+ // title for display - the admin bar renders 'title' as raw HTML.
+ 'id' => 'prpl-suggested-' . $key . '-' . $task->ID,
'parent' => 'prpl-suggested-' . $key,
- 'title' => $title . ' <a href="' . esc_url( $delete_url ) . '" style="color: #dc3232; display: inline-block; margin-left: 5px; text-decoration: none;">×</a>',
+ 'title' => esc_html( $title ) . ' <a href="' . esc_url( $delete_url ) . '" style="color: #dc3232; display: inline-block; margin-left: 5px; text-decoration: none;">×</a>',
]
);
}
@@ -307,7 +309,7 @@
[
'id' => 'prpl-activity-' . $activity->id,
'parent' => 'prpl-activities',
- 'title' => $activity->data_id . ' - ' . $activity->category,
+ 'title' => esc_html( $activity->data_id . ' - ' . $activity->category ),
]
);
}
--- a/progress-planner/progress-planner.php
+++ b/progress-planner/progress-planner.php
@@ -9,7 +9,7 @@
* Description: A plugin to help you fight procrastination and get things done.
* Requires at least: 6.6
* Requires PHP: 7.4
- * Version: 1.9.0
+ * Version: 1.9.1
* Author: Team Emilia Projects
* Author URI: https://prpl.fyi/about
* License: GPL-3.0+