diff --git a/.eslintrc.js b/.eslintrc.js index 728cc1699..545adecb4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,9 +4,9 @@ module.exports = { 'plugin:eslint-comments/recommended', ], parserOptions: { - ecmaVersion: "latest", + ecmaVersion: 'latest', }, rules: { - "no-console": "off", + 'no-console': 'off', }, }; diff --git a/.gitattributes b/.gitattributes index 8123f4237..313f925cd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,4 @@ phpstan.neon.dist export-ignore phpunit.xml.dist export-ignore README.md export-ignore .wordpress-org/* export-ignore +.vscode/* export-ignore diff --git a/.github/ISSUE_TEMPLATE/new-task-recommendation.yml b/.github/ISSUE_TEMPLATE/new-task-recommendation.yml index 1675be619..ce0edbcb9 100644 --- a/.github/ISSUE_TEMPLATE/new-task-recommendation.yml +++ b/.github/ISSUE_TEMPLATE/new-task-recommendation.yml @@ -28,28 +28,6 @@ body: validations: required: true - - type: dropdown - id: category - attributes: - label: Recommendation category - description: Select the most appropriate category for this recommendation - options: - - content-update - - configuration - - user - - other - validations: - required: true - - - type: input - id: other_category - attributes: - label: Other category - description: If you selected "other", please specify the category - placeholder: "Specify the category" - validations: - required: false - - type: input id: capability attributes: diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..81fcfb675 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,84 @@ +{ + "intelephense.stubs": [ + "apache", + "bcmath", + "bz2", + "calendar", + "com_dotnet", + "Core", + "ctype", + "curl", + "date", + "dba", + "dom", + "enchant", + "exif", + "FFI", + "fileinfo", + "filter", + "fpm", + "ftp", + "gd", + "gettext", + "gmp", + "hash", + "iconv", + "imap", + "intl", + "json", + "ldap", + "libxml", + "mbstring", + "meta", + "mysqli", + "oci8", + "odbc", + "openssl", + "pcntl", + "pcre", + "PDO", + "pdo_ibm", + "pdo_mysql", + "pdo_pgsql", + "pdo_sqlite", + "pgsql", + "Phar", + "posix", + "pspell", + "readline", + "Reflection", + "session", + "shmop", + "SimpleXML", + "snmp", + "soap", + "sockets", + "sodium", + "SPL", + "sqlite3", + "standard", + "superglobals", + "sysvmsg", + "sysvsem", + "sysvshm", + "tidy", + "tokenizer", + "xml", + "xmlreader", + "xmlrpc", + "xmlwriter", + "xsl", + "Zend OPcache", + "zip", + "zlib", + "wordpress", + "wordpress-globals", + "wp-cli", + "genesis", + "polylang" + ], + "intelephense.environment.includePaths": [ + "./vendor/yoast/wp-test-utils", + "./vendor/php-stubs/wordpress-stubs" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 55786a38d..5f72e8b67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ += 1.9.0 = + +In this release we've added an integration with the **All In One Seo** plugin so you’ll now see personalized suggestions based on your current SEO configuration. + +Added these recommendations from Ravi: + +* Install an SEO plugin +* Improve PDF handling if the site has many PDFs +* All In One SEO: [noindex the author archive](https://prpl.fyi/aioseo-author-archive) +* All In One SEO: [noindex the date archive](https://prpl.fyi/aioseo-date-archive) +* All In One SEO: [Remove post authors feeds](https://prpl.fyi/aioseo-crawl-optimization-feed-authors) +* All In One SEO: [Remove comment feeds](https://prpl.fyi/aioseo-crawl-optimization-feed-comments) +* All In One SEO: [disable the media pages](https://prpl.fyi/aioseo-media-pages) +* All In One SEO: set your [organization](https://prpl.fyi/aioseo-organization-logo) or [person](https://prpl.fyi/aioseo-person-logo) logo + +Enhancements: + +* Add "Show all Recommendations" button to the "Ravi's Recommendations" widget +* Performance improvements +* Removed the "Latest Badge" widget from the plugin page +* Styling improvements +* Improved task prioritization +* Converted many tasks to be "Interactive", improving user-experience. +* Dont display tasks to users which dont't have capability to complete them +* Proactively ran a complete security audit of the plugin, and strengthened security + +Bugs we fixed: + +* Fix missing content and streak badges after they are all completed. +* Fix issue when point is sometimes not awarded for completing "Remove term without posts" task. +* "Yoast SEO: set your organization logo" task detects site logo as fallback, same as Yoast SEO does. + = 1.8.1 = - Security fix: Privilege escalation via an AJAX call where authenticated users could update arbitrary site options. diff --git a/assets/css/admin.css b/assets/css/admin.css index 7367be958..192f8d591 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -12,35 +12,6 @@ --prpl-container-max-width: calc(var(--prpl-column-min-width) * var(--prpl-max-columns) + var(--prpl-gap) * (var(--prpl-max-columns) - 1) + var(--prpl-column-min-width)); - --prpl-color-gray-1: #e1e3e7; - --prpl-color-gray-2: #d1d5db; - --prpl-color-gray-3: #9ca3af; - --prpl-color-gray-4: #6b7280; - --prpl-color-gray-5: #4b5563; - --prpl-color-gray-6: #374151; - - --prpl-color-accent-red: #f43f5e; - --prpl-color-accent-orange: #faa310; - --prpl-color-400-orange: #f9b23c; - --prpl-color-accent-purple: #0d6b9e; - --prpl-color-accent-green: #14b8a6; - - --prpl-color-headings: #38296d; - --prpl-color-text: var(--prpl-color-gray-5); - --prpl-color-link: #1e40af; - - --prpl-color-notification-green: #16a34a; - --prpl-color-notification-red: #e73136; - - --prpl-color-button-primary: #dd3244; - --prpl-color-button-primary-hover: #cf2441; - - --prpl-background-orange: #fff9f0; - --prpl-background-purple: #f6f5fb; - --prpl-background-green: #f2faf9; - --prpl-background-red: #fff6f7; - --prpl-background-blue: #effbfe; - --prpl-font-size-xs: 0.75rem; /* 12px */ --prpl-font-size-small: 0.875rem; /* 14px */ --prpl-font-size-base: 1rem; /* 16px */ @@ -53,6 +24,26 @@ --prpl-font-size-6xl: 4.5rem; /* 72px */ } +/*------------------------------------*\ + Accessibility: Skip link +\*------------------------------------*/ +.prpl-skip-link { + position: absolute; + top: -40px; + left: 0; + background: var(--prpl-color-button-primary); + color: var(--prpl-color-button-primary-text); + padding: 8px 16px; + text-decoration: none; + border-radius: var(--prpl-border-radius); + z-index: 100000; +} + +.prpl-skip-link:focus { + top: 10px; + left: 10px; +} + /*------------------------------------*\ Progress Planner dashboard page. \*------------------------------------*/ @@ -60,7 +51,7 @@ body.toplevel_page_progress-planner { #wpwrap { - background-color: #f6f7f9; + background-color: var(--prpl-background); } ul#adminmenu { @@ -69,7 +60,7 @@ body.toplevel_page_progress-planner { >li.current > a.current { &::after { - border-right-color: #f6f7f9 !important; + border-right-color: var(--prpl-background) !important; } } } @@ -120,6 +111,16 @@ body.toplevel_page_progress-planner { color: var(--prpl-color-link); } +.prpl-wrap input[type="checkbox"] { + border-color: var(--prpl-color-selection-controls-inactive); +} + +.prpl-wrap select { + border-color: var(--prpl-color-field-border); + + /* box-shadow: 1px 2px 4px 0 var(--prpl-color-field-shadow); */ +} + .prpl-widget-width-2 { grid-column: span 2; @@ -149,22 +150,13 @@ body.toplevel_page_progress-planner { &:hover { border-radius: 50%; - background-color: var(--prpl-color-gray-4); - - &::before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - border: 2px solid var(--prpl-color-gray-4); - border-radius: 50%; - box-sizing: border-box; - } + + color: var(--prpl-color-ui-icon-hover); + border-color: var(--prpl-color-ui-icon-hover); + background-color: var(--prpl-color-ui-icon-hover-fill); svg path { - fill: #fff; + fill: var(--prpl-color-ui-icon-hover); } } } @@ -186,10 +178,14 @@ body.toplevel_page_progress-planner { button.prpl-info-icon { background: none; border: none; - color: var(--prpl-color-gray-4); + color: var(--prpl-color-ui-icon); cursor: pointer; font-size: var(--prpl-font-size-xs); padding: 0; + + svg path { + fill: var(--prpl-color-ui-icon); + } } /*------------------------------------*\ @@ -201,6 +197,10 @@ button.prpl-info-icon { flex-wrap: wrap; justify-content: space-between; align-items: center; + + .prpl-header-logo img { + height: 100px; + } } .prpl-header-logo svg { @@ -220,9 +220,9 @@ button.prpl-info-icon { justify-content: center; padding: 0.4em; - /* color: var(--prpl-color-gray-2); */ + /* color: var(--prpl-color-border); */ background-color: #fff; - border: 1px solid var(--prpl-color-gray-2); + border: 1px solid var(--prpl-color-ui-icon); border-radius: var(--prpl-border-radius); svg { @@ -235,9 +235,9 @@ button.prpl-info-icon { } &:hover { - color: var(--prpl-color-link); - border-color: var(--prpl-color-link); - background-color: var(--prpl-background-blue); + color: var(--prpl-color-ui-icon-hover); + border-color: var(--prpl-color-ui-icon-hover); + background-color: var(--prpl-color-ui-icon-hover-fill); } } } @@ -267,8 +267,8 @@ button.prpl-info-icon { Generic styles for individual widgets. \*------------------------------------*/ .prpl-widget-wrapper { - background-color: #fff; - border: 1px solid var(--prpl-color-gray-2); + background-color: var(--prpl-background-paper); + border: 1px solid var(--prpl-color-border); border-radius: var(--prpl-border-radius); padding: var(--prpl-padding); min-width: 0; @@ -279,7 +279,7 @@ button.prpl-info-icon { hr { margin-top: 1.25rem; margin-bottom: 1.25rem; - border-top-color: var(--prpl-color-gray-2); + border-top-color: var(--prpl-color-border); } /* Force the last column to be the last column. */ @@ -330,7 +330,8 @@ button.prpl-info-icon { .prpl-wrap button.prpl-button-secondary, .prpl-wrap a.prpl-button-primary, .prpl-wrap a.prpl-button-secondary, -#progress_planner_dashboard_widget_score a.prpl-button-primary { /* To be used in WP Dashboard widget */ +#progress_planner_dashboard_widget_score a.prpl-button-primary, +#progress_planner_dashboard_widget_score a.prpl-button-secondary { /* To be used in WP Dashboard widget */ display: block; margin: 1rem 0; padding: 0.75rem 1.25rem; @@ -384,13 +385,23 @@ button.prpl-info-icon { } .prpl-wrap input.prpl-button-secondary, -.prpl-wrap button.prpl-button-secondary { - background: var(--prpl-color-gray-3); +.prpl-wrap button.prpl-button-secondary, +.prpl-wrap a.prpl-button-secondary, +#progress_planner_dashboard_widget_score a.prpl-button-secondary { + background: var(--prpl-color-selection-controls-inactive); + + &::after { + background: var(--prpl-color-selection-controls-inactive); + } &:not([disabled]):hover, &:not([disabled]):focus { - background: var(--prpl-color-gray-4); - box-shadow: 3px 3px 10px var(--prpl-color-gray-4); + background: var(--prpl-color-ui-icon); + box-shadow: 3px 3px 10px var(--prpl-color-ui-icon); + + &::after { + background: var(--prpl-color-ui-icon); + } } } @@ -412,8 +423,8 @@ button.prpl-info-icon { Popovers generic styles. \*------------------------------------*/ .prpl-popover { - background: #fff; - border: 1px solid var(--prpl-color-gray-3); + background: var(--prpl-background-paper); + border: 1px solid var(--prpl-color-border); border-radius: var(--prpl-border-radius); padding: var(--prpl-padding); font-weight: 400; @@ -441,11 +452,11 @@ button.prpl-info-icon { cursor: pointer; background: none; border: none; - color: var(--prpl-color-gray-4); + color: var(--prpl-color-text); &:hover, &:focus { - color: var(--prpl-color-gray-6); + color: var(--prpl-color-text-hover); } } @@ -490,7 +501,7 @@ button.prpl-info-icon { } .driver-popover.prpl-driverjs-theme { - background-color: var(--prpl-background-orange); + background-color: var(--prpl-background-monthly); color: var(--prpl-color-text); .driver-popover-title { @@ -502,7 +513,7 @@ button.prpl-info-icon { } button:not(.driver-popover-close-btn):hover { - background-color: var(--prpl-background-orange); + background-color: var(--prpl-background-monthly); } } @@ -558,4 +569,3 @@ button.prpl-info-icon { transform: rotate(360deg); } } - diff --git a/assets/css/dashboard-widgets/score.css b/assets/css/dashboard-widgets/score.css index fca269f99..e1939c4c8 100644 --- a/assets/css/dashboard-widgets/score.css +++ b/assets/css/dashboard-widgets/score.css @@ -14,7 +14,7 @@ grid-gap: calc(var(--prpl-gap) / 2); > div { - border: 1px solid var(--prpl-color-gray-2); + border: 1px solid var(--prpl-color-border); border-radius: var(--prpl-border-radius); font-size: 0.875rem; text-align: center; diff --git a/assets/css/onboard.css b/assets/css/onboard.css index 16590ed97..bac24ea7d 100644 --- a/assets/css/onboard.css +++ b/assets/css/onboard.css @@ -1,8 +1,3 @@ -#progress-planner-scan-progress progress { - width: 100%; - min-height: 1px; -} - #prpl-onboarding-form .prpl-form-fields label { display: grid; grid-template-columns: 1fr 3fr; @@ -21,3 +16,8 @@ display: block !important; } } + +#prpl-onboarding-submit-wrapper { + display: flex; + align-items: center; +} diff --git a/assets/css/page-widgets/badge-streak.css b/assets/css/page-widgets/badge-streak.css index d2ff469b0..3fa64c456 100644 --- a/assets/css/page-widgets/badge-streak.css +++ b/assets/css/page-widgets/badge-streak.css @@ -15,23 +15,23 @@ } #popover-badge-streak-content .inner { - background: var(--prpl-background-red); + background: var(--prpl-background-streak); } #popover-badge-streak-maintenance .inner { - background: var(--prpl-background-blue); + background: var(--prpl-background-content-badge); } .badges-popover-progress-total { display: block; width: 100%; height: 20px; - background: var(--prpl-color-gray-1); + background: var(--prpl-color-gauge-remain); > span { display: block; height: 100%; - background: var(--prpl-color-accent-red); + background: var(--prpl-graph-color-1); } } diff --git a/assets/css/page-widgets/challenge.css b/assets/css/page-widgets/challenge.css index ba32d8acd..5807a744d 100644 --- a/assets/css/page-widgets/challenge.css +++ b/assets/css/page-widgets/challenge.css @@ -10,7 +10,7 @@ left: 0; width: 100%; height: 100%; - background: var(--prpl-color-gray-2); + background: var(--prpl-color-border); opacity: 0.4; } @@ -23,7 +23,7 @@ z-index: 1; width: calc(100% - (var(--prpl-padding) * 4)); background-color: #fff; - border: 1px solid var(--prpl-color-gray-2); + border: 1px solid var(--prpl-color-border); padding: var(--prpl-padding); border-radius: var(--prpl-border-radius); diff --git a/assets/css/page-widgets/content-activity.css b/assets/css/page-widgets/content-activity.css index 04c898c2a..454812464 100644 --- a/assets/css/page-widgets/content-activity.css +++ b/assets/css/page-widgets/content-activity.css @@ -29,7 +29,7 @@ tr { &:nth-child(odd) { - background-color: #f6f7f9; + background-color: var(--prpl-background-table); } } } @@ -47,7 +47,7 @@ th, td { text-align: start; - border-top: 1px solid var(--prpl-color-gray-2); + border-top: 1px solid var(--prpl-color-border); } } diff --git a/assets/css/page-widgets/latest-badge.css b/assets/css/page-widgets/latest-badge.css deleted file mode 100644 index a5c0907df..000000000 --- a/assets/css/page-widgets/latest-badge.css +++ /dev/null @@ -1,31 +0,0 @@ -.prpl-widget-wrapper.prpl-latest-badge { - - img { - margin-top: 0.5rem; - border: 1px solid var(--prpl-color-gray-2); - border-radius: var(--prpl-border-radius); - } - - .share-badge-wrapper { - padding-top: 1rem; - display: flex; - justify-content: flex-end; - } - - .prpl-button-share-badge { - display: flex; - align-items: center; - gap: 0.5em; - text-decoration: none; - color: var(--prpl-color-gray-7); - border: 1px solid var(--prpl-color-gray-2); - border-radius: var(--prpl-border-radius); - padding: 0.5em 1em; - - &:hover { - color: var(--prpl-color-link); - border-color: var(--prpl-color-link); - background-color: var(--prpl-background-blue); - } - } -} diff --git a/assets/css/page-widgets/monthly-badges.css b/assets/css/page-widgets/monthly-badges.css index 2d1d8eabc..16a14b8c8 100644 --- a/assets/css/page-widgets/monthly-badges.css +++ b/assets/css/page-widgets/monthly-badges.css @@ -75,7 +75,7 @@ .prpl-widget-title { padding: 0.5rem 1rem; border-radius: 0.5rem; - background: var(--prpl-background-orange); + background: var(--prpl-background-monthly); } .badge-group-monthly { @@ -85,7 +85,7 @@ .prpl-widget-wrapper-content { .prpl-widget-title { - background: var(--prpl-background-blue); + background: var(--prpl-background-content-badge); } .badge-group-content { @@ -96,7 +96,7 @@ .prpl-widget-wrapper-maintenance { .prpl-widget-title { - background: var(--prpl-background-red); + background: var(--prpl-background-streak); } .badge-group-maintenance { @@ -127,11 +127,11 @@ } &.badge-group-content { - background: var(--prpl-background-blue); + background: var(--prpl-background-content-badge); } &.badge-group-maintenance { - background: var(--prpl-background-red); + background: var(--prpl-background-streak); } .prpl-badge { diff --git a/assets/css/page-widgets/suggested-tasks.css b/assets/css/page-widgets/suggested-tasks.css index 937afd057..4f83b716c 100644 --- a/assets/css/page-widgets/suggested-tasks.css +++ b/assets/css/page-widgets/suggested-tasks.css @@ -19,26 +19,52 @@ } } + &:not(:has(.prpl-suggested-tasks-loading)):has(.prpl-suggested-tasks-list li) { + + .prpl-show-all-tasks { + display: block; + } + } + + .prpl-show-all-tasks { + display: none; + + .prpl-toggle-all-recommendations-button { + background: none; + border: none; + padding: 0; + color: var(--wp-admin-theme-color, #2271b1); + text-decoration: underline; + cursor: pointer; + font-size: inherit; + font-family: inherit; + + &:hover { + color: var(--wp-admin-theme-color-darker-10, #135e96); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + } + &:has(.prpl-suggested-tasks-list li) { .prpl-widget-title { display: flex; } - - hr { - display: block; - } } - .prpl-widget-title, - hr { + .prpl-widget-title { display: none; } .prpl-no-suggested-tasks, .prpl-suggested-tasks-loading { display: none; - background-color: var(--prpl-background-green); + background-color: var(--prpl-background-activity); padding: calc(var(--prpl-padding) / 2); } @@ -95,7 +121,7 @@ left: -20px; width: 1px; height: 100%; - background-color: var(--prpl-color-gray-2); + background-color: var(--prpl-color-border); } &::after { @@ -148,7 +174,7 @@ &.prpl-column-content { padding: 20px; border-radius: var(--prpl-border-radius-big); - background-color: var(--prpl-background-purple); + background-color: var(--prpl-background-content); } .prpl-note { @@ -157,10 +183,10 @@ align-items: flex-start; gap: 0.5rem; padding: 0.75rem; - color: #854d0e; + color: var(--prpl-color-alert-warning-text); font-size: var(--prpl-font-size-small); border-radius: 6px; - background-color: #fefce8; + background-color: var(--prpl-background-alert-warning); .prpl-note-icon { display: flex; @@ -169,7 +195,7 @@ justify-content: center; width: 20px; height: 20px; - color: #eab308; + color: var(--prpl-color-alert-warning); svg { width: 100%; @@ -178,11 +204,15 @@ } &.prpl-note-error { - color: #9f0712; - background-color: var(--prpl-background-red); + color: var(--prpl-color-alert-error-text); + background-color: var(--prpl-background-alert-error); + margin-bottom: 0; + + order: 98; /* One less than the spinner. */ + flex-grow: 1; .prpl-note-icon { - color: var(--prpl-color-notification-red); + color: var(--prpl-color-alert-error); } } @@ -211,16 +241,19 @@ /* WIP */ border-radius: 6px; - border: 1px solid var(--prpl-color-gray-2); + border: 1px solid var(--prpl-color-border); } + .prpl-button-step, .prpl-button { padding: 0.75rem 1.25rem; + margin: 1rem 0; border: none; - color: var(--prpl-color-gray-6); + color: var(--prpl-color-text); font-weight: 600; border-radius: var(--prpl-border-radius); - background-color: var(--prpl-color-400-orange); + background-color: var(--prpl-background-banner); + cursor: pointer; /* WIP: pick exact color */ transition: all 0.25s ease-in-out; @@ -231,7 +264,7 @@ display: block; width: 100%; height: 100%; - background: var(--prpl-color-400-orange); + background: var(--prpl-background-banner) !important; /* WIP: pick exact color */ position: absolute; @@ -244,12 +277,12 @@ &:hover, &:focus { - background: var(--prpl-color-400-orange); + background: var(--prpl-background-banner); /* WIP: pick exact color */ &::after { - background: var(--prpl-color-400-orange); + background: var(--prpl-background-banner); /* WIP: pick exact color */ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.15); @@ -265,7 +298,7 @@ min-height: 100px; border-radius: 6px; box-shadow: 1px 2px 4px 0 rgba(0, 0, 0, 0.05); - border: 1px solid var(--prpl-color-gray-2); + border: 1px solid var(--prpl-color-border); } /* Used for radio and checkbox inputs. */ @@ -412,12 +445,29 @@ padding-top: 1rem; display: flex; justify-content: flex-end; + align-items: center; gap: 1rem; align-self: flex-end; width: 100%; + /* If there are no other elements in the form, align the button to the left. */ + &:only-child { + padding-top: 0; + } + + &:only-child, + &.prpl-steps-nav-wrapper-align-left { + justify-content: flex-start; + + /* Display the spinner after the button. */ + .prpl-spinner { + order: 99; + } + } + .prpl-button { cursor: pointer; + margin: 0; /* If the button has empty data-action attribute disable it. */ &[data-action=""] { @@ -426,6 +476,11 @@ } } + /* Display the spinner before the button. */ + .prpl-spinner { + order: -1; + } + } } diff --git a/assets/css/page-widgets/todo.css b/assets/css/page-widgets/todo.css index a0e38292d..cdd6898d9 100644 --- a/assets/css/page-widgets/todo.css +++ b/assets/css/page-widgets/todo.css @@ -29,8 +29,8 @@ #todo-list > .prpl-suggested-task:first-child { - border: 1px solid #796da2; - background-color: #f6f7f9; + border: 1px solid var(--prpl-color-border-next-top-task); + background-color: var(--prpl-background-table); .prpl-suggested-task-points-wrapper { @@ -61,8 +61,8 @@ #todo-list { .prpl-suggested-task[data-task-points="1"] { - border: 1px solid var(--prpl-color-accent-orange); - background-color: var(--prpl-background-orange); + border: 1px solid var(--prpl-color-border-top-task); + background-color: var(--prpl-background-top-task); } } } @@ -79,12 +79,12 @@ border-radius: 50%; background: none; box-shadow: none; - color: var(--prpl-color-gray-3); display: flex; align-items: center; justify-content: center; padding: 0.2em; margin-inline-start: 0.3rem; + color: var(--prpl-color-ui-icon); .dashicons { font-size: 0.825em; @@ -126,8 +126,8 @@ #todo-list > .prpl-suggested-task:first-child { - border: 1px solid #796da2; - background-color: #f6f7f9; + border: 1px solid var(--prpl-color-border-next-top-task); + background-color: var(--prpl-background-table); .prpl-suggested-task-points-wrapper { @@ -150,8 +150,8 @@ #todo-list { .prpl-suggested-task[data-task-points="1"] { - border: 1px solid var(--prpl-color-accent-orange); - background-color: var(--prpl-background-orange); + border: 1px solid var(--prpl-color-border-top-task); + background-color: var(--prpl-background-top-task); } } } @@ -203,7 +203,7 @@ #todo-list-completed-details { margin-top: 1rem; - border: 1px solid var(--prpl-color-gray-3); + border: 1px solid var(--prpl-color-border); border-radius: 0.5rem; summary { @@ -218,6 +218,10 @@ height: 20px; transition: transform 0.3s ease-in-out; + + svg { + stroke: var(--prpl-color-ui-icon); + } } } @@ -234,7 +238,7 @@ #todo-list-completed-delete-all-wrapper { margin: 0.25rem 0.5rem 0.75rem 0.5rem; - border-top: 1px solid var(--prpl-color-gray-2); + border-top: 1px solid var(--prpl-color-border); display: none; #todo-list-completed-delete-all { @@ -249,11 +253,15 @@ color: var(--prpl-color-link); font-size: var(--prpl-font-size-small); + svg path { + fill: var(--prpl-color-ui-icon); + } + &:hover { text-decoration: underline; svg path { - fill: var(--prpl-color-accent-red); + fill: var(--prpl-color-ui-icon-hover-delete); } } @@ -273,8 +281,8 @@ position: absolute; right: 0; transition: all 0.3s ease-in-out; - color: #38296d; - background-color: #d1d5db; + color: var(--prpl-text-point-inactive); + background-color: var(--prpl-background-point-inactive); } .prpl-suggested-task-button { @@ -288,10 +296,14 @@ opacity: 0; transition: opacity 0.3s ease-in-out; + svg path { + fill: var(--prpl-color-ui-icon); + } + &:hover { svg path { - fill: var(--prpl-color-accent-red); + fill: var(--prpl-color-ui-icon-hover-delete); } } } @@ -340,10 +352,10 @@ align-items: flex-start; gap: 0.5rem; padding: 0.75rem; - color: #854d0e; + color: var(--prpl-color-alert-warning-text); font-size: var(--prpl-font-size-small); border-radius: 6px; - background-color: #fefce8; + background-color: var(--prpl-background-alert-warning); .prpl-note-icon { display: flex; @@ -352,23 +364,13 @@ justify-content: center; width: 20px; height: 20px; - color: #eab308; + color: var(--prpl-color-alert-warning); svg { width: 100%; height: 100%; } } - - &.prpl-note-error { - color: #9f0712; - background-color: var(--prpl-background-red); - - .prpl-note-icon { - color: var(--prpl-color-notification-red); - } - - } } .prpl-buttons-wrapper { diff --git a/assets/css/page-widgets/whats-new.css b/assets/css/page-widgets/whats-new.css index 598f4e2ce..1a7137609 100644 --- a/assets/css/page-widgets/whats-new.css +++ b/assets/css/page-widgets/whats-new.css @@ -46,7 +46,7 @@ text-decoration: underline; &:hover { - color: var(--prpl-color-gray-6); + color: var(--prpl-color-link-hover); text-decoration: none; } } @@ -60,8 +60,8 @@ background-size: cover; margin-bottom: 0.75rem; border-radius: var(--prpl-border-radius-big); - border: 1px solid var(--prpl-color-gray-2); - background-color: var(--prpl-color-gray-1); /* Fallback, if remote host image is not accessible */ + border: 1px solid var(--prpl-color-border); + background-color: var(--prpl-color-gauge-remain); /* Fallback, if remote host image is not accessible */ transition: transform 0.2s, box-shadow 0.2s; &:hover { diff --git a/assets/css/settings-page.css b/assets/css/settings-page.css index 789bd5040..70d431ed6 100644 --- a/assets/css/settings-page.css +++ b/assets/css/settings-page.css @@ -2,7 +2,7 @@ .progress-planner_page_progress-planner-settings { #wpwrap { - background-color: #f6f7f9; + background-color: var(--prpl-background); } ul#adminmenu { @@ -11,7 +11,7 @@ > li.current > a.current { &::after { - border-right-color: #f6f7f9 !important; + border-right-color: var(--prpl-background) !important; } } } @@ -38,16 +38,16 @@ } .prpl-settings-form-wrap { - background-color: #fff; + background-color: var(--prpl-background-paper); - border: 1px solid var(--prpl-color-gray-2); + border: 1px solid var(--prpl-color-border); border-radius: var(--prpl-border-radius); padding: var(--prpl-padding); box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.07), -2px 0 6px rgba(0, 0, 0, 0.07); } .prpl-settings-section-wrapper { - border: 1px solid var(--prpl-color-gray-2); + border: 1px solid var(--prpl-color-border); border-radius: var(--prpl-border-radius); padding: var(--prpl-padding); flex-grow: 1; @@ -57,7 +57,7 @@ display: flex; align-items: center; gap: 0.5rem; - background: var(--prpl-background-orange); + background: var(--prpl-background-monthly); padding: 1.2rem; border-radius: 0.5rem; margin-bottom: var(--prpl-padding); @@ -73,7 +73,7 @@ } .prpl-pages-item { - border: 1px solid var(--prpl-color-gray-2); + border: 1px solid var(--prpl-color-border); border-radius: var(--prpl-border-radius); padding: var(--prpl-padding); flex-grow: 1; @@ -177,11 +177,12 @@ margin-bottom: var(--prpl-gap); .prpl-settings-section-title { + background: var(--prpl-background-setting-pages); .icon { path { - fill: var(--prpl-color-accent-orange); + fill: var(--prpl-color-setting-pages-icon); } } } @@ -213,7 +214,7 @@ .prpl-button { color: var(--prpl-color-gray-7); text-decoration: none; - border: 1px solid var(--prpl-color-gray-2); + border: 1px solid var(--prpl-color-border); border-radius: var(--prpl-border-radius); padding: 0.5em 0.5em; font-size: 14px; /* It matches -
@@ -63,7 +64,7 @@ public function the_todo_list() {
-
- - +
+ +
'', 'parent' => 0, 'provider_id' => $this->get_provider_id(), - 'category' => $this->get_provider_category(), 'priority' => 'medium', 'points' => 0, 'url' => '', diff --git a/classes/suggested-tasks/providers/class-content-create.php b/classes/suggested-tasks/providers/class-content-create.php index 5158bcf57..4f038b0f6 100644 --- a/classes/suggested-tasks/providers/class-content-create.php +++ b/classes/suggested-tasks/providers/class-content-create.php @@ -21,13 +21,6 @@ class Content_Create extends Tasks { */ protected const PROVIDER_ID = 'create-post'; - /** - * The provider category. - * - * @var string - */ - protected const CATEGORY = 'content-new'; - /** * The capability required to perform the task. * diff --git a/classes/suggested-tasks/providers/class-content-review.php b/classes/suggested-tasks/providers/class-content-review.php index 2bc092355..f0898f586 100644 --- a/classes/suggested-tasks/providers/class-content-review.php +++ b/classes/suggested-tasks/providers/class-content-review.php @@ -30,13 +30,6 @@ class Content_Review extends Tasks { */ protected const PROVIDER_ID = 'review-post'; - /** - * The provider category. - * - * @var string - */ - protected const CATEGORY = 'content-update'; - /** * The external link URL. * @@ -63,7 +56,7 @@ class Content_Review extends Tasks { * * @var int */ - protected $priority = 30; + protected $priority = 10; /** * Whether the task is dismissable. @@ -294,7 +287,6 @@ public function get_tasks_to_inject() { $task_to_inject[] = [ 'task_id' => $this->get_task_id( [ 'target_post_id' => $task_data['target_post_id'] ] ), 'provider_id' => $this->get_provider_id(), - 'category' => $this->get_provider_category(), 'target_post_id' => $task_data['target_post_id'], 'target_post_type' => $task_data['target_post_type'], 'date' => \gmdate( 'YW' ), @@ -302,6 +294,7 @@ public function get_tasks_to_inject() { 'url' => $this->get_url_with_data( $task_data ), 'url_target' => $this->get_url_target(), 'dismissable' => $this->is_dismissable(), + 'priority' => $this->get_priority(), 'points' => $this->get_points(), 'external_link_url' => $this->get_external_link_url(), ]; diff --git a/classes/suggested-tasks/providers/class-core-update.php b/classes/suggested-tasks/providers/class-core-update.php index a084958ff..714ace08e 100644 --- a/classes/suggested-tasks/providers/class-core-update.php +++ b/classes/suggested-tasks/providers/class-core-update.php @@ -12,13 +12,6 @@ */ class Core_Update extends Tasks { - /** - * The provider category. - * - * @var string - */ - protected const CATEGORY = 'maintenance'; - /** * The provider ID. * @@ -52,7 +45,7 @@ class Core_Update extends Tasks { * * @var int */ - protected $priority = 0; + protected $priority = 20; /** * Get the task URL. @@ -94,7 +87,7 @@ public function add_core_update_link( $update_actions ) { // All updates are completed and there is a 'update-core' task in the published tasks. if ( $this->is_task_completed() ) { foreach ( \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'post_status' => 'publish' ] ) as $task ) { - if ( $this->get_task_id() === $task->task_id ) { + if ( $this->get_task_id() === \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ) { $update_actions['prpl_core_update'] = 'Progress Planner' . '' . \esc_html__( 'Click here to celebrate your completed task!', 'progress-planner' ) . ''; diff --git a/classes/suggested-tasks/providers/class-debug-display.php b/classes/suggested-tasks/providers/class-debug-display.php index e5fe09f0c..bdbfd2322 100644 --- a/classes/suggested-tasks/providers/class-debug-display.php +++ b/classes/suggested-tasks/providers/class-debug-display.php @@ -33,6 +33,13 @@ class Debug_Display extends Tasks { */ protected const EXTERNAL_LINK_URL = 'https://prpl.fyi/set-wp-debug'; + /** + * The task priority. + * + * @var int + */ + protected $priority = 10; + /** * Get the task title. * diff --git a/classes/suggested-tasks/providers/class-disable-comment-pagination.php b/classes/suggested-tasks/providers/class-disable-comment-pagination.php index b39ba163e..3d46db9e2 100644 --- a/classes/suggested-tasks/providers/class-disable-comment-pagination.php +++ b/classes/suggested-tasks/providers/class-disable-comment-pagination.php @@ -40,6 +40,13 @@ class Disable_Comment_Pagination extends Tasks_Interactive { */ protected const EXTERNAL_LINK_URL = 'https://prpl.fyi/disable-comment-pagination'; + /** + * The task priority. + * + * @var int + */ + protected $priority = 10; + /** * Get the task URL. * @@ -102,7 +109,7 @@ public function print_popover_instructions() { /* translators: %d is the number of comments per page, %s is the "recommend to disable comment pagination" link */ \esc_html__( 'When comment pagination is enabled, your site creates a new page for every %1$d comments. This is not helping your website in search engines, and can break up the ongoing conversation. That\'s why we %2$s.', 'progress-planner' ), (int) \get_option( 'comments_per_page' ), - '' . \esc_html__( 'recommend to disable comment pagination', 'progress-planner' ) . '' + '' . \esc_html__( 'recommend to disable comment pagination', 'progress-planner' ) . '' ); echo '

'; } @@ -113,11 +120,7 @@ public function print_popover_instructions() { * @return void */ public function print_popover_form_contents() { - ?> - - print_submit_button( \__( 'Disable comment pagination', 'progress-planner' ) ); } /** diff --git a/classes/suggested-tasks/providers/class-disable-comments.php b/classes/suggested-tasks/providers/class-disable-comments.php index dc1f45e4b..6631fc3c8 100644 --- a/classes/suggested-tasks/providers/class-disable-comments.php +++ b/classes/suggested-tasks/providers/class-disable-comments.php @@ -40,6 +40,13 @@ class Disable_Comments extends Tasks_Interactive { */ protected const EXTERNAL_LINK_URL = 'https://prpl.fyi/disable-comments'; + /** + * The task priority. + * + * @var int + */ + protected $priority = 9; + /** * Get the task URL. * @@ -137,9 +144,11 @@ public function print_popover_instructions() { */ public function print_popover_form_contents() { ?> - +
+ +
email_subject = \esc_html__( 'Your Progress Planner test message!', 'progress-planner' ); + + // Generate a secure token for the completion link to prevent CSRF. + $user_id = \get_current_user_id(); + $token = \progress_planner()->get_suggested_tasks()->generate_task_completion_token( $this->get_task_id(), $user_id ); + $this->email_content = \sprintf( // translators: %1$s the admin URL. - \__( 'You just used Progress Planner to verify if sending email works on your website.

The good news; it does! Click here to mark Ravi\'s Recommendation as completed.', 'progress-planner' ), - \admin_url( 'admin.php?page=progress-planner&prpl_complete_task=' . $this->get_task_id() ) + \__( 'You just used Progress Planner to verify if sending email works on your website.

The good news; it does! Click here to mark %2$s\'s Recommendation as completed.', 'progress-planner' ), + \admin_url( 'admin.php?page=progress-planner&prpl_complete_task=' . $this->get_task_id() . '&token=' . $token ), + \esc_html( \progress_planner()->get_ui__branding()->get_ravi_name() ) ); } + /** + * Get the troubleshooting guide URL. + * + * @return string + */ + protected function get_troubleshooting_guide_url() { + return \esc_url( \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/troubleshoot-smtp' ) ); + } + /** * We want task to be added always. * @@ -207,7 +208,7 @@ public function enqueue_scripts( $hook ) { 'ajax_url' => \admin_url( 'admin-ajax.php' ), 'nonce' => \wp_create_nonce( 'progress_planner' ), 'unknown_error' => \esc_html__( 'Unknown error', 'progress-planner' ), - 'troubleshooting_guide_url' => $this->troubleshooting_guide_url, + 'troubleshooting_guide_url' => $this->get_troubleshooting_guide_url(), ], ] ); @@ -252,6 +253,8 @@ protected function is_there_sending_email_override() { /** * Test email sending. * + * Use check_ajax_referer and get email from $_POST. + * * @return void */ public function ajax_test_email_sending() { @@ -260,18 +263,32 @@ public function ajax_test_email_sending() { \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to test email sending.', 'progress-planner' ) ] ); } - // Check the nonce. - \check_admin_referer( 'progress_planner' ); + // Use check_ajax_referer for AJAX handlers. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } - $email_address = isset( $_GET['email_address'] ) ? \sanitize_email( \wp_unslash( $_GET['email_address'] ) ) : ''; + // Get email from POST data (AJAX request). + $email_address = isset( $_POST['email_address'] ) ? \sanitize_email( \wp_unslash( $_POST['email_address'] ) ) : ''; if ( ! $email_address ) { \wp_send_json_error( \esc_html__( 'Invalid email address.', 'progress-planner' ) ); } + // Regenerate email content with fresh token for current user. + $user_id = \get_current_user_id(); + $token = \progress_planner()->get_suggested_tasks()->generate_task_completion_token( $this->get_task_id(), $user_id ); + + $email_content = \sprintf( + // translators: %1$s the admin URL. + \__( 'You just used Progress Planner to verify if sending email works on your website.

The good news; it does! Click here to mark %2$s\'s Recommendation as completed.', 'progress-planner' ), + \admin_url( 'admin.php?page=progress-planner&prpl_complete_task=' . $this->get_task_id() . '&token=' . $token ), + \esc_html( \progress_planner()->get_ui__branding()->get_ravi_name() ) + ); + $headers = [ 'Content-Type: text/html; charset=UTF-8' ]; - $result = \wp_mail( $email_address, $this->email_subject, $this->email_content, $headers ); + $result = \wp_mail( $email_address, $this->email_subject, $email_content, $headers ); if ( $result ) { \wp_send_json_success( \esc_html__( 'Email sent successfully.', 'progress-planner' ) ); @@ -304,7 +321,7 @@ public function the_popover_content() { 'prpl_provider_id' => $this->get_provider_id(), 'prpl_email_subject' => $this->email_subject, 'prpl_email_error' => $this->email_error, - 'prpl_troubleshooting_guide_url' => $this->troubleshooting_guide_url, + 'prpl_troubleshooting_guide_url' => $this->get_troubleshooting_guide_url(), 'prpl_is_there_sending_email_override' => $this->is_there_sending_email_override(), 'prpl_task_actions' => $this->get_task_actions(), ] diff --git a/classes/suggested-tasks/providers/class-fewer-tags.php b/classes/suggested-tasks/providers/class-fewer-tags.php index 65bcf8d16..643e3cb6c 100644 --- a/classes/suggested-tasks/providers/class-fewer-tags.php +++ b/classes/suggested-tasks/providers/class-fewer-tags.php @@ -14,7 +14,7 @@ /** * Add tasks to check if Fewer Tags plugin is installed. */ -class Fewer_Tags extends Tasks { +class Fewer_Tags extends Tasks_Interactive { /** * Whether the task is an onboarding task. @@ -24,18 +24,18 @@ class Fewer_Tags extends Tasks { protected const IS_ONBOARDING_TASK = true; /** - * The provider type. + * The provider ID. * * @var string */ - const CATEGORY = 'configuration'; + const PROVIDER_ID = 'fewer-tags'; /** - * The provider ID. + * The popover ID. * * @var string */ - const PROVIDER_ID = 'fewer-tags'; + const POPOVER_ID = 'fewer-tags'; /** * The external link URL. @@ -49,7 +49,7 @@ class Fewer_Tags extends Tasks { * * @var int */ - protected $priority = 10; + protected $priority = 32; /** * The plugin active state. @@ -87,9 +87,11 @@ class Fewer_Tags extends Tasks { protected $is_dismissable = true; /** - * Constructor. + * Initialize the task provider. + * + * @return void */ - public function __construct() { + public function init() { // Data collectors. $this->post_tag_count_data_collector = new Post_Tag_Count(); $this->published_post_count_data_collector = new Published_Post_Count(); @@ -162,6 +164,39 @@ protected function is_plugin_active() { return $this->is_plugin_active; } + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \printf( + // translators: %1$s is the number of tags, %2$s is the number of published posts. + \esc_html__( 'Your site has %1$s tags across %2$s published posts. Having too many tags can dilute your content organization and hurt SEO. The "Fewer Tags" plugin helps you consolidate similar tags.', 'progress-planner' ), + (int) $this->post_tag_count_data_collector->collect(), + (int) $this->published_post_count_data_collector->collect(), + ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + if ( ! \is_multisite() && \current_user_can( 'install_plugins' ) ) : ?> + + 10, - 'html' => '' . \esc_html__( 'Install plugin', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Install plugin', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/class-hello-world.php b/classes/suggested-tasks/providers/class-hello-world.php index 278e6a505..fe431f419 100644 --- a/classes/suggested-tasks/providers/class-hello-world.php +++ b/classes/suggested-tasks/providers/class-hello-world.php @@ -56,6 +56,13 @@ class Hello_World extends Tasks_Interactive { */ protected const EXTERNAL_LINK_URL = 'https://prpl.fyi/delete-hello-world-post'; + /** + * The task priority. + * + * @var int + */ + protected $priority = 15; + /** * Get the task URL. * @@ -132,11 +139,7 @@ public function should_add_task() { * @return void */ public function print_popover_form_contents() { - ?> - - print_submit_button( \__( 'Delete the "Hello World!" post', 'progress-planner' ) ); } /** diff --git a/classes/suggested-tasks/providers/class-improve-pdf-handling.php b/classes/suggested-tasks/providers/class-improve-pdf-handling.php new file mode 100644 index 000000000..74f3bbcf4 --- /dev/null +++ b/classes/suggested-tasks/providers/class-improve-pdf-handling.php @@ -0,0 +1,212 @@ +get_utils__cache()->get( 'pdf_files_count' ); + if ( false === $pdf_files_count ) { + // Detect if there are more than 10 PDF files. + $query = new \WP_Query( + [ + 'post_type' => 'attachment', + 'post_mime_type' => 'application/pdf', + 'post_status' => 'inherit', + 'posts_per_page' => static::MIN_PDF_FILES + 1, // We want to get at least 11 PDF files to be sure we have enough. + 'fields' => 'ids', + ] + ); + $pdf_files_count = $query->found_posts; + \progress_planner()->get_utils__cache()->set( 'pdf_files_count', $pdf_files_count, DAY_IN_SECONDS ); + } + + return static::MIN_PDF_FILES < (int) $pdf_files_count; + } + + /** + * Task should be completed only manually by the user. + * + * @param string $task_id The task ID. + * + * @return bool + */ + public function is_task_completed( $task_id = '' ) { + return false; + } + + /** + * Task should be completed only manually by the user. + * + * @param string $task_id The task ID. + * + * @return bool|string + */ + public function evaluate_task( $task_id ) { + return false; + } + + /** + * Get the title. + * + * @return string + */ + protected function get_title() { + return \esc_html__( 'Improve PDF handling', 'progress-planner' ); + } + + /** + * Get the description. + * + * @param array $task_data Optional data to include in the task. + * @return string + */ + protected function get_description( $task_data = [] ) { + return \esc_html__( 'Your site seems to have quite a few PDF files, we can improve the way your site handles them.', 'progress-planner' ); + } + + /** + * Enqueue the scripts. + * + * @param string $hook The current admin page. + * + * @return void + */ + public function enqueue_scripts( $hook ) { + // Enqueue the script only on Progress Planner and WP dashboard pages. + if ( 'toplevel_page_progress-planner' !== $hook && 'index.php' !== $hook ) { + return; + } + + // Don't enqueue the script if the task is already completed. + if ( true === \progress_planner()->get_suggested_tasks()->was_task_completed( $this->get_task_id() ) ) { + return; + } + + // Enqueue the web component. + \progress_planner()->get_admin__enqueue()->enqueue_script( + 'progress-planner/web-components/prpl-task-' . $this->get_provider_id(), + ); + } + + /** + * The popover content. + * + * @return void + */ + public function the_popover_content() { + \progress_planner()->the_view( + 'popovers/improve-pdf-handling.php', + [ + 'prpl_popover_id' => static::POPOVER_ID, + 'prpl_provider_id' => $this->get_provider_id(), + ] + ); + } + + /** + * Print the popover form contents. + * + * @return void + */ + public function print_popover_form_contents() { + // The form is handled in the popovers/email-sending view. + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Improve PDF handling', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/class-permalink-structure.php b/classes/suggested-tasks/providers/class-permalink-structure.php index cd17ac1fc..b2a4f292d 100644 --- a/classes/suggested-tasks/providers/class-permalink-structure.php +++ b/classes/suggested-tasks/providers/class-permalink-structure.php @@ -10,7 +10,7 @@ /** * Add tasks for permalink structure. */ -class Permalink_Structure extends Tasks { +class Permalink_Structure extends Tasks_Interactive { /** * Whether the task is an onboarding task. @@ -26,6 +26,13 @@ class Permalink_Structure extends Tasks { */ protected const PROVIDER_ID = 'core-permalink-structure'; + /** + * The popover ID. + * + * @var string + */ + const POPOVER_ID = 'core-permalink-structure'; + /** * The external link URL. * @@ -33,6 +40,38 @@ class Permalink_Structure extends Tasks { */ protected const EXTERNAL_LINK_URL = 'https://prpl.fyi/change-default-permalink-structure'; + /** + * The task priority. + * + * @var int + */ + protected $priority = 3; + + /** + * Initialize the task. + * + * @return void + */ + public function init() { + \add_action( 'wp_ajax_prpl_interactive_task_submit_core-permalink-structure', [ $this, 'handle_interactive_task_specific_submit' ] ); + \add_action( 'init', [ $this, 'maybe_flush_rewrite_rules' ] ); + } + + /** + * Maybe flush rewrite rules. + * + * Checks for a transient flag and flushes rewrite rules if needed. + * This is more performant than flushing immediately during the AJAX request. + * + * @return void + */ + public function maybe_flush_rewrite_rules() { + if ( \get_transient( 'prpl_flush_rewrite_rules' ) ) { + \flush_rewrite_rules(); + \delete_transient( 'prpl_flush_rewrite_rules' ); + } + } + /** * Get the task URL. * @@ -89,6 +128,160 @@ public function should_add_task() { return '/%year%/%monthnum%/%day%/%postname%/' === $permalink_structure || '/index.php/%year%/%monthnum%/%day%/%postname%/' === $permalink_structure; } + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'By default, WordPress uses date-based URLs (e.g., /2025/01/21/post-name/) which can make your content seem outdated. SEO-friendly URLs help search engines and visitors better understand your content.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $permalink_structure = \get_option( 'permalink_structure' ); + $prefix = \got_url_rewrite() ? '' : '/index.php'; + $url_base = \home_url( $prefix ); + $index_php_prefix = \got_url_rewrite() ? '' : '/index.php'; + + // Default structure values from WP core. + $structures = [ + [ + 'id' => 'day-name', + 'value' => $index_php_prefix . '/%year%/%monthnum%/%day%/%postname%/', + 'label' => \__( 'Day and name', 'progress-planner' ), + 'code' => $url_base . '/' . \gmdate( 'Y/m/d' ) . '/sample-post/', + ], + [ + 'id' => 'month-name', + 'value' => $index_php_prefix . '/%year%/%monthnum%/%postname%/', + 'label' => \__( 'Month and name', 'progress-planner' ), + 'code' => $url_base . '/' . \gmdate( 'Y/m' ) . '/sample-post/', + ], + [ + 'id' => 'numeric', + 'value' => $index_php_prefix . '/archives/%post_id%', + 'label' => \__( 'Numeric', 'progress-planner' ), + 'code' => $url_base . '/archives/123', + ], + [ + 'id' => 'post-name', + 'value' => $index_php_prefix . '/%postname%/', + 'label' => \__( 'Post name', 'progress-planner' ), + 'code' => $url_base . '/sample-post/', + ], + ]; + + $default_structure_values = \wp_list_pluck( $structures, 'value' ); + $is_custom = ! \in_array( $permalink_structure, $default_structure_values, true ); + ?> +
+
+ +
+ +
+ + + +
+ +
+ +
+ + +
+
+
+ print_submit_button( \__( 'Set permalink structure', 'progress-planner' ) ); + } + + /** + * Handle the interactive task submit. + * + * This is only for interactive tasks that change core permalink settings. + * The $_POST data is expected to be: + * - value: (mixed) The value to update the setting to. + * - nonce: (string) The nonce. + * + * @return void + */ + public function handle_interactive_task_specific_submit() { + + // Check if the user has the necessary capabilities. + if ( ! \current_user_can( 'manage_options' ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + if ( ! isset( $_POST['value'] ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Missing value.', 'progress-planner' ) ] ); + } + + $permalink_structure = \trim( \sanitize_text_field( \wp_unslash( $_POST['value'] ) ) ); + + if ( empty( $permalink_structure ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid permalink structure.', 'progress-planner' ) ] ); + } + + // Update the permalink structure. + \update_option( 'permalink_structure', $permalink_structure ); + + // Set a transient to flush rewrite rules on the next page load. + // This is more performant than flushing immediately during the AJAX request. + \set_transient( 'prpl_flush_rewrite_rules', 1, HOUR_IN_SECONDS ); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Permalink structure updated.', 'progress-planner' ) ] ); + } + /** * Add task actions specific to this task. * @@ -100,7 +293,7 @@ public function should_add_task() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Go to the "Permalinks" page', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Select permalink structure', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/class-php-version.php b/classes/suggested-tasks/providers/class-php-version.php index 94be9efee..798634ba9 100644 --- a/classes/suggested-tasks/providers/class-php-version.php +++ b/classes/suggested-tasks/providers/class-php-version.php @@ -40,6 +40,13 @@ class Php_Version extends Tasks { */ protected const EXTERNAL_LINK_URL = 'https://prpl.fyi/update-php-version'; + /** + * The task priority. + * + * @var int + */ + protected $priority = 25; + /** * Get the title. * diff --git a/classes/suggested-tasks/providers/class-remove-inactive-plugins.php b/classes/suggested-tasks/providers/class-remove-inactive-plugins.php index bb5c6cddd..44dc218a3 100644 --- a/classes/suggested-tasks/providers/class-remove-inactive-plugins.php +++ b/classes/suggested-tasks/providers/class-remove-inactive-plugins.php @@ -42,6 +42,13 @@ class Remove_Inactive_Plugins extends Tasks { */ protected const EXTERNAL_LINK_URL = 'https://prpl.fyi/remove-inactive-plugins'; + /** + * The task priority. + * + * @var int + */ + protected $priority = 60; + /** * Get the task URL. * diff --git a/classes/suggested-tasks/providers/class-remove-terms-without-posts.php b/classes/suggested-tasks/providers/class-remove-terms-without-posts.php index d5a86263f..98ed13ce3 100644 --- a/classes/suggested-tasks/providers/class-remove-terms-without-posts.php +++ b/classes/suggested-tasks/providers/class-remove-terms-without-posts.php @@ -1,19 +1,19 @@ public ) { + return; + } + foreach ( \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'provider_id' => $this->get_provider_id() ] ) as $task ) { /** * The task post object. @@ -128,7 +132,13 @@ public function maybe_remove_irrelevant_tasks( $object_id, $terms, $tt_ids, $tax if ( $task->target_term_id && $task->target_taxonomy ) { $term = \get_term( $task->target_term_id, $task->target_taxonomy ); - if ( \is_wp_error( $term ) || ! $term || $term->count > self::MIN_POSTS ) { + // If the term is NULL it means the term was deleted, but we want to keep the task (and award a point). + if ( ! $term ) { + continue; + } + + // If the taxonomy is not found the $term will be a WP_Error object. + if ( \is_wp_error( $term ) || $term->count > self::MIN_POSTS ) { \progress_planner()->get_suggested_tasks_db()->delete_recommendation( $task->ID ); } } @@ -143,6 +153,11 @@ public function maybe_remove_irrelevant_tasks( $object_id, $terms, $tt_ids, $tax * @return string */ protected function get_title_with_data( $task_data = [] ) { + + if ( ! isset( $task_data['target_term_id'] ) || ! isset( $task_data['target_taxonomy'] ) ) { + return ''; + } + $term = \get_term( $task_data['target_term_id'], $task_data['target_taxonomy'] ); return ( $term && ! \is_wp_error( $term ) ) ? \sprintf( @@ -161,6 +176,11 @@ protected function get_title_with_data( $task_data = [] ) { * @return string */ protected function get_url_with_data( $task_data = [] ) { + + if ( ! isset( $task_data['target_term_id'] ) || ! isset( $task_data['target_taxonomy'] ) ) { + return ''; + } + $term = \get_term( $task_data['target_term_id'], $task_data['target_taxonomy'] ); return ( $term && ! \is_wp_error( $term ) ) ? \admin_url( 'term.php?taxonomy=' . $term->taxonomy . '&tag_ID=' . $term->term_id ) @@ -309,11 +329,137 @@ public function exclude_completed_terms( $exclude_term_ids ) { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { + + if ( ! isset( $data['slug'] ) ) { + return $actions; + } + + $term = $this->get_term_from_task_id( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $data['slug'] ) ); + + // If the term is not found, return the actions. + if ( ! $term ) { + return $actions; + } + + $task_data = [ + 'target_term_id' => $term->term_id, + 'target_taxonomy' => $term->taxonomy, + 'target_term_name' => $term->name, + ]; + + $task_details = $this->get_task_details( $task_data ); + + $taxonomy = \get_taxonomy( $term->taxonomy ); + $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Go to the "Taxonomies" page', 'progress-planner' ) . '', + 'html' => \sprintf( + ' + %s + ', + \htmlspecialchars( + \wp_json_encode( + [ + 'post_title' => $task_details['post_title'], + 'target_term_id' => $task_data['target_term_id'], + 'target_taxonomy' => $task_data['target_taxonomy'], + 'target_taxonomy_name' => $taxonomy ? $taxonomy->label : '', + 'target_term_name' => $task_data['target_term_name'], + ] + ), + ENT_QUOTES, + 'UTF-8' + ), + \esc_attr( static::POPOVER_ID ), + \esc_html__( 'Delete term', 'progress-planner' ) + ), ]; return $actions; } + + /** + * Print the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'This term has no posts assigned to it. Removing unused terms keeps your site organized, improves navigation, and prevents empty archive pages. Note: This action cannot be undone.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover form contents. + * + * @return void + */ + public function print_popover_form_contents() { + ?> +
+

+ + +

+

+ ', + '' + ); + ?> +

+
+ + +
+ +
+ \esc_html__( 'You do not have permission to delete terms.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + if ( ! isset( $_POST['term_id'] ) || ! isset( $_POST['taxonomy'] ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Missing term information.', 'progress-planner' ) ] ); + } + + $term_id = \absint( \wp_unslash( $_POST['term_id'] ) ); + $taxonomy = \sanitize_text_field( \wp_unslash( $_POST['taxonomy'] ) ); + + // Verify the term exists. + $term = \get_term( $term_id, $taxonomy ); + if ( ! $term || \is_wp_error( $term ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Term not found.', 'progress-planner' ) ] ); + } + + // Delete the term. + $result = \wp_delete_term( $term_id, $taxonomy ); + + if ( \is_wp_error( $result ) ) { + \wp_send_json_error( [ 'message' => $result->get_error_message() ] ); + } + + \wp_send_json_success( [ 'message' => \esc_html__( 'Term deleted successfully.', 'progress-planner' ) ] ); + } } diff --git a/classes/suggested-tasks/providers/class-rename-uncategorized-category.php b/classes/suggested-tasks/providers/class-rename-uncategorized-category.php index 4e457ff20..5f54763b4 100644 --- a/classes/suggested-tasks/providers/class-rename-uncategorized-category.php +++ b/classes/suggested-tasks/providers/class-rename-uncategorized-category.php @@ -12,7 +12,7 @@ /** * Add task to rename the Uncategorized category. */ -class Rename_Uncategorized_Category extends Tasks { +class Rename_Uncategorized_Category extends Tasks_Interactive { /** * Whether the task is an onboarding task. @@ -28,6 +28,13 @@ class Rename_Uncategorized_Category extends Tasks { */ protected const PROVIDER_ID = 'rename-uncategorized-category'; + /** + * The popover ID. + * + * @var string + */ + const POPOVER_ID = 'rename-uncategorized-category'; + /** * The capability required to perform the task. * @@ -49,6 +56,22 @@ class Rename_Uncategorized_Category extends Tasks { */ protected const EXTERNAL_LINK_URL = 'https://prpl.fyi/rename-uncategorized-category'; + /** + * The task priority. + * + * @var int + */ + protected $priority = 60; + + /** + * Initialize the task. + * + * @return void + */ + public function init() { + \add_action( 'wp_ajax_prpl_interactive_task_submit_rename-uncategorized-category', [ $this, 'handle_interactive_task_specific_submit' ] ); + } + /** * Get the task URL. * @@ -85,6 +108,109 @@ public function update_uncategorized_category_cache() { $this->get_data_collector()->update_uncategorized_category_cache(); // @phpstan-ignore-line method.notFound } + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'WordPress assigns posts to "Uncategorized" by default if no category is selected. Renaming this to something meaningful (like "General" or your main topic) creates a better user experience and looks more professional.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $uncategorized_category_id = $this->get_data_collector()->collect(); + $uncategorized_category = \get_term( $uncategorized_category_id, 'category' ); + + if ( ! $uncategorized_category || \is_wp_error( $uncategorized_category ) ) { + return; + } + ?> + + + + \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + if ( ! isset( $_POST['uncategorized_category_name'] ) || ! isset( $_POST['uncategorized_category_slug'] ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Missing uncategorized category name or slug.', 'progress-planner' ) ] ); + } + + $uncategorized_category_name = \trim( \sanitize_text_field( \wp_unslash( $_POST['uncategorized_category_name'] ) ) ); + $uncategorized_category_slug = \trim( \sanitize_text_field( \wp_unslash( $_POST['uncategorized_category_slug'] ) ) ); + + if ( empty( $uncategorized_category_name ) || empty( $uncategorized_category_slug ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid uncategorized category name or slug.', 'progress-planner' ) ] ); + } + + $default_category_name = \__( 'Uncategorized' ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain + $default_category_slug = \sanitize_title( \_x( 'Uncategorized', 'Default category slug' ) ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain + + $strtolower = \function_exists( 'mb_strtolower' ) ? 'mb_strtolower' : 'strtolower'; + if ( $strtolower( $uncategorized_category_name ) === $strtolower( $default_category_name ) || $strtolower( $uncategorized_category_slug ) === $strtolower( $default_category_slug ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'You cannot use the default name or slug for the Uncategorized category.', 'progress-planner' ) ] ); + } + + $uncategorized_category_id = $this->get_data_collector()->collect(); + + $term = \get_term( $uncategorized_category_id, 'category' ); + if ( ! $term || \is_wp_error( $term ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Uncategorized category not found.', 'progress-planner' ) ] ); + } + + \wp_update_term( + $term->term_id, + 'category', + [ + 'name' => $uncategorized_category_name, + 'slug' => $uncategorized_category_slug, + ] + ); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Uncategorized category updated.', 'progress-planner' ) ] ); + } + /** * Add task actions specific to this task. * @@ -96,7 +222,7 @@ public function update_uncategorized_category_cache() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Rename', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Rename', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/class-sample-page.php b/classes/suggested-tasks/providers/class-sample-page.php index 887c91b0e..e7ae313cf 100644 --- a/classes/suggested-tasks/providers/class-sample-page.php +++ b/classes/suggested-tasks/providers/class-sample-page.php @@ -56,6 +56,13 @@ class Sample_Page extends Tasks_Interactive { */ protected const EXTERNAL_LINK_URL = 'https://prpl.fyi/delete-sample-page'; + /** + * The task priority. + * + * @var int + */ + protected $priority = 14; + /** * Get the task URL. * @@ -131,11 +138,7 @@ public function should_add_task() { * @return void */ public function print_popover_form_contents() { - ?> - - print_submit_button( \__( 'Delete the "Sample Page" page', 'progress-planner' ) ); } /** diff --git a/classes/suggested-tasks/providers/class-search-engine-visibility.php b/classes/suggested-tasks/providers/class-search-engine-visibility.php index 154923b6f..54ec0dd7f 100644 --- a/classes/suggested-tasks/providers/class-search-engine-visibility.php +++ b/classes/suggested-tasks/providers/class-search-engine-visibility.php @@ -10,7 +10,7 @@ /** * Add task to allow search engines to index the site. */ -class Search_Engine_Visibility extends Tasks { +class Search_Engine_Visibility extends Tasks_Interactive { /** * Whether the task is an onboarding task. @@ -26,6 +26,13 @@ class Search_Engine_Visibility extends Tasks { */ protected const PROVIDER_ID = 'search-engine-visibility'; + /** + * The popover ID. + * + * @var string + */ + const POPOVER_ID = 'search-engine-visibility'; + /** * The external link URL. * @@ -33,6 +40,22 @@ class Search_Engine_Visibility extends Tasks { */ protected const EXTERNAL_LINK_URL = 'https://prpl.fyi/blog-indexing-settings'; + /** + * The task priority. + * + * @var int + */ + protected $priority = 5; + + /** + * Initialize the task. + * + * @return void + */ + public function init() { + \add_action( 'wp_ajax_prpl_interactive_task_submit_search-engine-visibility', [ $this, 'handle_interactive_task_specific_submit' ] ); + } + /** * Get the task URL. * @@ -72,6 +95,54 @@ public function should_add_task() { return 0 === (int) \get_option( 'blog_public' ); } + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'Your site is currently hidden from search engines like Google and Bing. This setting is useful during development, but prevents people from finding your content through search. If your site is ready to go live, you should enable search engine visibility.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $this->print_submit_button( \__( 'Allow search engines to index your site', 'progress-planner' ) ); + } + + /** + * Handle the interactive task submit. + * + * This is only for interactive tasks that change non-core settings. + * The $_POST data is expected to be: + * - blog_public: (string) The blog public to update. + * - nonce: (string) The nonce. + * + * @return void + */ + public function handle_interactive_task_specific_submit() { + + // Check if the user has the necessary capabilities. + if ( ! \current_user_can( 'manage_options' ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + // We're not checking for the return value of the update_option calls, because it will return false if the value is the same. + \update_option( 'blog_public', '1' ); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); + } + /** * Add task actions specific to this task. * @@ -83,7 +154,7 @@ public function should_add_task() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Change setting', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Allow', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/class-select-locale.php b/classes/suggested-tasks/providers/class-select-locale.php index bf4a70d0f..88ac6bfcd 100644 --- a/classes/suggested-tasks/providers/class-select-locale.php +++ b/classes/suggested-tasks/providers/class-select-locale.php @@ -54,6 +54,13 @@ class Select_Locale extends Tasks_Interactive { */ protected $is_dismissable = true; + /** + * The task priority. + * + * @var int + */ + protected $priority = 8; + /** * Initialize the task. * @@ -151,12 +158,10 @@ protected function get_locales() { // Get the locales. $locales = \array_map( - function ( $locale ) { - return [ - 'code' => $locale['language'], - 'name' => $locale['native_name'], - ]; - }, + fn( $locale ) => [ + 'code' => $locale['language'], + 'name' => $locale['native_name'], + ], $locales['translations'] ); @@ -173,14 +178,14 @@ function ( $locale ) { * @return bool */ public function is_task_completed( $task_id = '' ) { - $locale_activity = \progress_planner()->get_activities__query()->query_activities( + $activity = \progress_planner()->get_activities__query()->query_activities( [ 'category' => 'suggested_task', 'data_id' => static::PROVIDER_ID, ] ); - return ! empty( $locale_activity ); + return ! empty( $activity ); } /** @@ -220,11 +225,8 @@ public function print_popover_form_contents() { 'show_available_translations' => \current_user_can( 'install_languages' ) && \wp_can_install_language_pack(), ] ); - ?> - - print_submit_button( \__( 'Select locale', 'progress-planner' ), 'prpl-steps-nav-wrapper-align-left' ); } /** diff --git a/classes/suggested-tasks/providers/class-select-timezone.php b/classes/suggested-tasks/providers/class-select-timezone.php index adcf0dd33..3eb54c8bf 100644 --- a/classes/suggested-tasks/providers/class-select-timezone.php +++ b/classes/suggested-tasks/providers/class-select-timezone.php @@ -40,6 +40,13 @@ class Select_Timezone extends Tasks_Interactive { */ protected $is_dismissable = true; + /** + * The task priority. + * + * @var int + */ + protected $priority = 6; + /** * Initialize the task. * @@ -85,14 +92,14 @@ protected function get_title() { * @return bool */ public function should_add_task() { - $timezone_activity = \progress_planner()->get_activities__query()->query_activities( + $activity = \progress_planner()->get_activities__query()->query_activities( [ 'category' => 'suggested_task', 'data_id' => static::PROVIDER_ID, ] ); - return ! $timezone_activity; + return ! $activity; } /** @@ -136,10 +143,8 @@ public function print_popover_form_contents() { - print_submit_button( \__( 'Set site timezone', 'progress-planner' ), 'prpl-steps-nav-wrapper-align-left' ); } /** diff --git a/classes/suggested-tasks/providers/class-seo-plugin.php b/classes/suggested-tasks/providers/class-seo-plugin.php new file mode 100644 index 000000000..d170a247f --- /dev/null +++ b/classes/suggested-tasks/providers/class-seo-plugin.php @@ -0,0 +1,206 @@ +get_data_collector()->collect(); + } + + /** + * Check if the task is completed. + * + * @param string $task_id The task ID. + * + * @return bool + */ + public function is_task_completed( $task_id = '' ) { + // Task is completed if an SEO plugin is detected. + return $this->get_data_collector()->collect(); + } + + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + $seo_plugin_recommendation_slug = \progress_planner()->get_ui__branding()->get_seo_plugin_recommendation_slug(); + $seo_plugin_recommendation_name = $this->get_plugin_name_by_slug( $seo_plugin_recommendation_slug ); + if ( ! $seo_plugin_recommendation_name ) { + return; + } + ?> +

+ +

+

+ %2$s', + \esc_attr( $seo_plugin_recommendation_slug ), + \esc_html( $seo_plugin_recommendation_name ) + ) + ); + ?> +

+ get_ui__branding()->get_seo_plugin_recommendation_slug(); + $seo_plugin_recommendation_name = $this->get_plugin_name_by_slug( $seo_plugin_recommendation_slug ); + if ( ! $seo_plugin_recommendation_name ) { + return; + } + + ?> + + + +

+ +

+

+ + + +

+ + get_data_collector(); + if ( ! $data_collector instanceof SEO_Plugin_Data_Collector ) { + return null; + } + + $seo_plugins = $data_collector->get_seo_plugins(); + if ( ! isset( $seo_plugins[ $slug ]['name'] ) ) { + return null; + } + + return $seo_plugins[ $slug ]['name']; + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Install plugin', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/class-set-date-format.php b/classes/suggested-tasks/providers/class-set-date-format.php index 0fe0e7325..909881ef7 100644 --- a/classes/suggested-tasks/providers/class-set-date-format.php +++ b/classes/suggested-tasks/providers/class-set-date-format.php @@ -33,6 +33,13 @@ class Set_Date_Format extends Tasks_Interactive { */ protected $is_dismissable = true; + /** + * The task priority. + * + * @var int + */ + protected $priority = 7; + /** * Initialize the task. * @@ -114,13 +121,13 @@ public function print_popover_instructions() { $detected_date_format = $this->get_date_format_type(); // Get the site default language name. - $available_languages = wp_get_available_translations(); - $site_locale = get_locale(); + $available_languages = \wp_get_available_translations(); + $site_locale = \get_locale(); if ( isset( $available_languages[ $site_locale ] ) ) { $lang_name = $available_languages[ $site_locale ]['english_name']; - } elseif ( function_exists( 'locale_get_display_name' ) ) { - $lang_name = locale_get_display_name( $site_locale, 'en' ); + } elseif ( \function_exists( 'locale_get_display_name' ) ) { + $lang_name = \locale_get_display_name( $site_locale, 'en' ); } else { $lang_name = $site_locale; } @@ -160,11 +167,11 @@ public function print_popover_form_contents() { $localized_default_date_format = \__( 'F j, Y' ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- We want localized date format from WP Core. // Try to get the localized default date format. - $site_locale = get_locale(); - $user_locale = get_user_locale(); - if ( $user_locale !== $site_locale && switch_to_locale( $site_locale ) ) { + $site_locale = \get_locale(); + $user_locale = \get_user_locale(); + if ( $user_locale !== $site_locale && \switch_to_locale( $site_locale ) ) { $localized_default_date_format = \__( 'F j, Y' ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- We want localized date format from WP Core. - restore_previous_locale(); + \restore_previous_locale(); } /** @@ -235,10 +242,8 @@ public function print_popover_form_contents() {

- print_submit_button( \__( 'Set date format', 'progress-planner' ), 'prpl-steps-nav-wrapper-align-left' ); } /** diff --git a/classes/suggested-tasks/providers/class-settings-saved.php b/classes/suggested-tasks/providers/class-settings-saved.php index 562cd5093..5fe62f14c 100644 --- a/classes/suggested-tasks/providers/class-settings-saved.php +++ b/classes/suggested-tasks/providers/class-settings-saved.php @@ -24,7 +24,7 @@ class Settings_Saved extends Tasks { * * @var int */ - protected $priority = 1; + protected $priority = 10; /** * Whether the task is an onboarding task. diff --git a/classes/suggested-tasks/providers/class-site-icon.php b/classes/suggested-tasks/providers/class-site-icon.php index 40f3f90e1..7bf34c85f 100644 --- a/classes/suggested-tasks/providers/class-site-icon.php +++ b/classes/suggested-tasks/providers/class-site-icon.php @@ -10,7 +10,7 @@ /** * Add tasks for Core siteicon. */ -class Site_Icon extends Tasks { +class Site_Icon extends Tasks_Interactive { /** * Whether the task is an onboarding task. @@ -26,6 +26,13 @@ class Site_Icon extends Tasks { */ protected const PROVIDER_ID = 'core-siteicon'; + /** + * The popover ID. + * + * @var string + */ + const POPOVER_ID = 'core-siteicon'; + /** * The external link URL. * @@ -33,6 +40,13 @@ class Site_Icon extends Tasks { */ protected const EXTERNAL_LINK_URL = 'https://prpl.fyi/set-site-icon'; + /** + * The task priority. + * + * @var int + */ + protected $priority = 1; + /** * Get the link setting. * @@ -73,6 +87,62 @@ public function should_add_task() { return '' === $site_icon || '0' === $site_icon; } + /** + * Print the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'Site Icons are what you see in browser tabs, bookmark bars, and within the WordPress mobile apps. Upload an image to make your site stand out.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover form contents. + * + * @return void + */ + public function print_popover_form_contents() { + // Enqueue media scripts. + \wp_enqueue_media(); + + $site_icon_id = \get_option( 'site_icon' ); + ?> +
+ + 'max-width: 150px; height: auto; border-radius: 4px; border: 1px solid #ddd;' ] ); ?> + + + +
+ + +
+ +
+ 'prplSiteIcon', + 'data' => [ + 'mediaTitle' => \esc_html__( 'Choose Site Icon', 'progress-planner' ), + 'mediaButtonText' => \esc_html__( 'Use as Site Icon', 'progress-planner' ), + ], + ]; + } + /** * Add task actions specific to this task. * @@ -84,7 +154,7 @@ public function should_add_task() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Go to the settings page', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Set site icon', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/class-tasks-interactive.php b/classes/suggested-tasks/providers/class-tasks-interactive.php index a70f7c4fd..9e3c3a54f 100644 --- a/classes/suggested-tasks/providers/class-tasks-interactive.php +++ b/classes/suggested-tasks/providers/class-tasks-interactive.php @@ -88,6 +88,18 @@ public function handle_interactive_task_submit() { $value = \sanitize_text_field( \wp_unslash( $_POST['value'] ) ); $setting_path = \json_decode( \sanitize_text_field( \wp_unslash( $_POST['setting_path'] ) ), true ); + // Whitelist allowed options to prevent arbitrary options update. + // This prevents privilege escalation by restricting which options can be updated. + $allowed_options = $this->get_allowed_interactive_options(); + + if ( ! \in_array( $setting, $allowed_options, true ) ) { + \wp_send_json_error( + [ + 'message' => \esc_html__( 'Invalid setting. This option cannot be updated through interactive tasks.', 'progress-planner' ), + ] + ); + } + if ( ! empty( $setting_path ) ) { $setting_value = \get_option( $setting ); \_wp_array_set( $setting_value, $setting_path, $value ); @@ -105,6 +117,47 @@ public function handle_interactive_task_submit() { \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); } + /** + * Get the list of allowed options that can be updated via interactive tasks. + * + * This whitelist prevents privilege escalation by ensuring only specific + * WordPress options can be modified through the interactive task interface. + * + * @return array List of allowed option names. + */ + protected function get_allowed_interactive_options() { + $allowed_options = [ + // Core WordPress settings that are safe to update via interactive tasks. + 'blogdescription', // Site tagline. + 'default_comment_status', // Comment settings. + 'default_ping_status', // Pingback settings. + 'timezone_string', // Site timezone. + 'WPLANG', // Site language/locale (deprecated since WP 4.0, but still used by class-select-locale.php). + 'date_format', // Date format. + 'time_format', // Time format. + 'default_pingback_flag', // Pingback flag. + 'comment_registration', // Comment registration. + 'close_comments_for_old_posts', // Close comments for old posts. + 'thread_comments', // Threaded comments. + 'comments_per_page', // Comments per page. + 'comment_order', // Comment order. + 'page_comments', // Paginate comments. + ]; + + /** + * Filter the list of allowed options for interactive tasks. + * + * WARNING: Be very careful when extending this list. Adding sensitive + * options like 'admin_email', 'users_can_register', or plugin-specific + * options that control access or permissions could create security vulnerabilities. + * + * @param array $allowed_options List of allowed option names. + * + * @return array Modified list of allowed option names. + */ + return \apply_filters( 'progress_planner_interactive_task_allowed_options', $allowed_options ); + } + /** * Add the popover. * @@ -153,6 +206,30 @@ public function print_popover_instructions() { echo '

' . \wp_kses_post( $description ) . '

'; } + /** + * Print the submit button. + * + * @param string $button_text The text for the button. + * If empty, the default text "Submit" will be used. + * @param string $css_class The CSS class for the wrapper. + * + * @return void + */ + protected function print_submit_button( $button_text = '', $css_class = '' ) { + if ( empty( $button_text ) ) { + $button_text = \__( 'Submit', 'progress-planner' ); + } + + $css_class = empty( $css_class ) ? 'prpl-steps-nav-wrapper' : 'prpl-steps-nav-wrapper ' . $css_class; + ?> +
+ +
+ link_setting; } - /** - * Alias for get_provider_category(), to provide backwards compatibility. - * - * @return string - */ - public function get_provider_type() { - \_deprecated_function( 'Progress_Planner\Suggested_Tasks\Providers\Tasks::get_provider_type()', '1.1.1', 'get_provider_category' ); - return $this->get_provider_category(); - } - - /** - * Get the provider category. - * - * @return string - */ - public function get_provider_category() { - return static::CATEGORY; - } - /** * Get the provider ID. * @@ -284,7 +261,7 @@ public function get_provider_id() { * @return string */ public function get_external_link_url() { - return static::EXTERNAL_LINK_URL; + return \progress_planner()->get_ui__branding()->get_url( static::EXTERNAL_LINK_URL ); } /** @@ -389,7 +366,7 @@ public function is_onboarding_task() { */ public function is_task_snoozed() { foreach ( \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'post_status' => 'future' ] ) as $task ) { - $task = \progress_planner()->get_suggested_tasks_db()->get_post( $task->task_id ); + $task = \progress_planner()->get_suggested_tasks_db()->get_post( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ); $provider_id = $task ? $task->get_provider_id() : ''; if ( $provider_id === $this->get_provider_id() ) { @@ -432,10 +409,10 @@ public function evaluate_task( $task_id ) { if ( ! $this->is_repetitive() ) { // Collaborator tasks have custom task_ids, so strpos check does not work for them. - if ( ! $task->task_id || ( 0 !== \strpos( $task->task_id, $this->get_task_id() ) && 'collaborator' !== $this->get_provider_id() ) ) { + if ( ! $task->post_name || ( 0 !== \strpos( $task->post_name, $this->get_task_id() ) && 'collaborator' !== $this->get_provider_id() ) ) { return false; } - return $this->is_task_completed( $task->task_id ) ? $task : false; + return $this->is_task_completed( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ) ? $task : false; } if ( @@ -443,7 +420,7 @@ public function evaluate_task( $task_id ) { $task->provider->slug === $this->get_provider_id() && \DateTime::createFromFormat( 'Y-m-d H:i:s', $task->post_date ) && \gmdate( 'YW' ) === \gmdate( 'YW', \DateTime::createFromFormat( 'Y-m-d H:i:s', $task->post_date )->getTimestamp() ) && // @phpstan-ignore-line - $this->is_task_completed( $task->task_id ) + $this->is_task_completed( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ) ) { // Allow adding more data, for example in case of 'create-post' tasks we are adding the post_id. $task_data = $this->modify_evaluated_task_data( $task->get_data() ); @@ -556,7 +533,6 @@ public function get_task_details( $task_data = [] ) { 'description' => $this->get_description_with_data( $task_data ), 'parent' => $this->get_parent(), 'priority' => $this->get_priority(), - 'category' => $this->get_provider_category(), 'points' => $this->get_points(), 'date' => \gmdate( 'YW' ), 'url' => $this->get_url_with_data( $task_data ), @@ -629,12 +605,12 @@ public function get_task_actions( $data = [] ) { if ( $this->capability_required() && $this->is_dismissable() && 'user' !== static::PROVIDER_ID ) { $actions[] = [ 'priority' => 20, - 'html' => '', + 'html' => '', ]; } if ( $this->capability_required() && $this->is_snoozable() ) { - $snooze_html = ''; + $snooze_html = ''; $snooze_html .= '
' . \esc_html__( 'Snooze this task?', 'progress-planner' ) . '
'; foreach ( [ @@ -645,7 +621,7 @@ public function get_task_actions( $data = [] ) { '1-year' => \esc_html__( '1 year', 'progress-planner' ), 'forever' => \esc_html__( 'forever', 'progress-planner' ), ] as $snooze_key => $snooze_value ) { - $snooze_html .= ''; + $snooze_html .= ''; } $snooze_html .= '
'; $actions[] = [ @@ -662,7 +638,7 @@ public function get_task_actions( $data = [] ) { } elseif ( isset( $data['content']['rendered'] ) && $data['content']['rendered'] !== '' && ! $this instanceof Tasks_Interactive ) { $actions[] = [ 'priority' => 40, - 'html' => '' . \wp_kses_post( $data['content']['rendered'] ) . '', + 'html' => '' . \wp_kses_post( $data['content']['rendered'] ) . '', ]; } @@ -678,12 +654,7 @@ public function get_task_actions( $data = [] ) { } // Order actions by priority. - \usort( - $actions, - function ( $a, $b ) { - return $a['priority'] - $b['priority']; - } - ); + \usort( $actions, fn( $a, $b ) => $a['priority'] - $b['priority'] ); $return_actions = []; foreach ( $actions as $action ) { @@ -704,4 +675,26 @@ function ( $a, $b ) { public function add_task_actions( $data = [], $actions = [] ) { return $actions; } + + /** + * Check if the task has activity. + * + * @param string $task_id The task ID. + * + * @return bool + */ + public function task_has_activity( $task_id = '' ) { + if ( empty( $task_id ) ) { + $task_id = $this->get_task_id(); + } + + $activity = \progress_planner()->get_activities__query()->query_activities( + [ + 'category' => 'suggested_task', + 'data_id' => $task_id, + ] + ); + + return ! empty( $activity ); + } } diff --git a/classes/suggested-tasks/providers/class-unpublished-content.php b/classes/suggested-tasks/providers/class-unpublished-content.php index 6b06bc1f9..ba26be1fc 100644 --- a/classes/suggested-tasks/providers/class-unpublished-content.php +++ b/classes/suggested-tasks/providers/class-unpublished-content.php @@ -30,13 +30,6 @@ class Unpublished_Content extends Tasks { */ protected const PROVIDER_ID = 'unpublished-content'; - /** - * The provider category. - * - * @var string - */ - protected const CATEGORY = 'content-publish'; - /** * The data collector class name. * @@ -63,7 +56,7 @@ class Unpublished_Content extends Tasks { * * @var int */ - protected $priority = 30; + protected $priority = 55; /** * Whether the task is dismissable. diff --git a/classes/suggested-tasks/providers/class-update-term-description.php b/classes/suggested-tasks/providers/class-update-term-description.php index 819eb258e..c5c671e23 100644 --- a/classes/suggested-tasks/providers/class-update-term-description.php +++ b/classes/suggested-tasks/providers/class-update-term-description.php @@ -7,13 +7,13 @@ namespace Progress_Planner\Suggested_Tasks\Providers; -use Progress_Planner\Suggested_Tasks\Providers\Tasks; +use Progress_Planner\Suggested_Tasks\Providers\Tasks_Interactive; use Progress_Planner\Suggested_Tasks\Data_Collector\Terms_Without_Description as Terms_Without_Description_Data_Collector; /** * Add task to update term description. */ -class Update_Term_Description extends Tasks { +class Update_Term_Description extends Tasks_Interactive { /** * Whether the task is an onboarding task. @@ -30,11 +30,11 @@ class Update_Term_Description extends Tasks { protected const PROVIDER_ID = 'update-term-description'; /** - * The provider category. + * The popover ID. * * @var string */ - protected const CATEGORY = 'content-update'; + const POPOVER_ID = 'update-term-description'; /** * The capability required to perform the task. @@ -91,8 +91,9 @@ class Update_Term_Description extends Tasks { public function init() { // Maybe remove tasks when term is deleted. \add_action( 'delete_term', [ $this, 'maybe_remove_irrelevant_tasks' ], 10, 5 ); - \add_filter( 'progress_planner_terms_without_description_exclude_term_ids', [ $this, 'exclude_completed_terms' ] ); + + \add_action( 'wp_ajax_prpl_interactive_task_submit_update-term-description', [ $this, 'handle_interactive_task_submit' ] ); } /** @@ -106,6 +107,14 @@ public function init() { * @return void */ public function maybe_remove_irrelevant_tasks( $term, $tt_id, $taxonomy, $deleted_term, $object_ids ) { + + $taxonomy_object = \get_taxonomy( $taxonomy ); + + // Check if the taxonomy is public, we are not interested in non-public taxonomies (also our data collector doesn't track non-public taxonomies). + if ( ! $taxonomy_object || ! $taxonomy_object->public ) { + return; + } + $pending_tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'provider_id' => $this->get_provider_id() ] ); if ( ! $pending_tasks ) { @@ -127,6 +136,10 @@ public function maybe_remove_irrelevant_tasks( $term, $tt_id, $taxonomy, $delete * @return string */ protected function get_title_with_data( $task_data = [] ) { + if ( ! isset( $task_data['target_term_id'] ) || ! isset( $task_data['target_taxonomy'] ) ) { + return ''; + } + $term = \get_term( $task_data['target_term_id'], $task_data['target_taxonomy'] ); return $term && ! \is_wp_error( $term ) ? \sprintf( /* translators: %s: The term name */ @@ -143,6 +156,10 @@ protected function get_title_with_data( $task_data = [] ) { * @return string */ protected function get_url_with_data( $task_data = [] ) { + if ( ! isset( $task_data['target_term_id'] ) || ! isset( $task_data['target_taxonomy'] ) ) { + return ''; + } + $term = \get_term( $task_data['target_term_id'], $task_data['target_taxonomy'] ); return $term && ! \is_wp_error( $term ) ? \admin_url( 'term.php?taxonomy=' . $term->taxonomy . '&tag_ID=' . $term->term_id ) @@ -297,20 +314,150 @@ public function exclude_completed_terms( $exclude_term_ids ) { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - if ( ! isset( $data['meta']['prpl_task_id'] ) ) { + if ( ! isset( $data['slug'] ) ) { return $actions; } - $term = $this->get_term_from_task_id( $data['meta']['prpl_task_id'] ); + $term = $this->get_term_from_task_id( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $data['slug'] ) ); + + // If the term is not found, return the actions. if ( ! $term ) { return $actions; } + $task_data = [ + 'target_term_id' => $term->term_id, + 'target_taxonomy' => $term->taxonomy, + 'target_term_name' => $term->name, + ]; + + $task_details = $this->get_task_details( $task_data ); + + $taxonomy = \get_taxonomy( $term->taxonomy ); + $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Write description', 'progress-planner' ) . '', + 'html' => \sprintf( + ' + %s + ', + \htmlspecialchars( + \wp_json_encode( + [ + 'post_title' => $task_details['post_title'], + 'target_term_id' => $task_data['target_term_id'], + 'target_taxonomy' => $task_data['target_taxonomy'], + 'target_taxonomy_name' => $taxonomy ? $taxonomy->label : '', + 'target_term_name' => $task_data['target_term_name'], + ] + ), + ENT_QUOTES, + 'UTF-8' + ), + \esc_attr( static::POPOVER_ID ), + \esc_html__( 'Write description', 'progress-planner' ) + ), ]; return $actions; } + + /** + * Print the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'Term descriptions appear on category and tag archive pages, helping visitors understand what to expect. They also provide important context for search engines, which can improve your SEO.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover form contents. + * + * @return void + */ + public function print_popover_form_contents() { + ?> +
+

+ + +

+

+ ', + '' + ); + ?> +

+
+ + + +
+ +
+ \esc_html__( 'You do not have permission to update terms.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + if ( ! isset( $_POST['term_id'] ) || ! isset( $_POST['taxonomy'] ) || ! isset( $_POST['description'] ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Missing term information.', 'progress-planner' ) ] ); + } + + $term_id = \absint( \wp_unslash( $_POST['term_id'] ) ); + $taxonomy = \sanitize_text_field( \wp_unslash( $_POST['taxonomy'] ) ); + $description = \wp_kses_post( \wp_unslash( $_POST['description'] ) ); + + // Verify the term exists. + $term = \get_term( $term_id, $taxonomy ); + if ( ! $term || \is_wp_error( $term ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Term not found.', 'progress-planner' ) ] ); + } + + // Update the term description. + $result = \wp_update_term( + $term_id, + $taxonomy, + [ + 'description' => $description, + ] + ); + + if ( \is_wp_error( $result ) ) { + \wp_send_json_error( [ 'message' => $result->get_error_message() ] ); + } + + \wp_send_json_success( [ 'message' => \esc_html__( 'Term description updated successfully.', 'progress-planner' ) ] ); + } } diff --git a/classes/suggested-tasks/providers/class-user.php b/classes/suggested-tasks/providers/class-user.php index 3b2a0eefa..91e6b13b8 100644 --- a/classes/suggested-tasks/providers/class-user.php +++ b/classes/suggested-tasks/providers/class-user.php @@ -34,18 +34,18 @@ class User extends Tasks { protected const IS_ONBOARDING_TASK = false; /** - * The provider category. + * The provider ID. * * @var string */ - protected const CATEGORY = 'user'; + protected const PROVIDER_ID = 'user'; /** - * The provider ID. - * - * @var string + * Constructor. */ - protected const PROVIDER_ID = 'user'; + public function __construct() { + \add_filter( 'progress_planner_suggested_tasks_in_rest_format', [ $this, 'modify_task_details_for_user_tasks_rest_format' ], 10, 2 ); + } /** * Check if the task should be added. @@ -94,4 +94,38 @@ public function add_task_actions( $data = [], $actions = [] ) { return $actions; } + + /** + * Modify the task details for user tasks in REST format. + * + * @param array $tasks The tasks. + * @param array $args The arguments. + * + * @return array + */ + public function modify_task_details_for_user_tasks_rest_format( $tasks, $args ) { + static $modified_tasks = []; + // Only process when fetching user tasks (include_provider contains 'user'). + if ( ! isset( $args['include_provider'] ) || ! \in_array( 'user', $args['include_provider'], true ) ) { + return $tasks; + } + + // Loop through all tasks in the flat array. + foreach ( $tasks as $key => $task ) { + // Only process user provider tasks. + if ( ! isset( $task['prpl_provider']->slug ) || $task['prpl_provider']->slug !== self::PROVIDER_ID ) { // @phpstan-ignore-line property.nonObject + continue; + } + + if ( \in_array( $task['id'], $modified_tasks, true ) ) { + continue; + } + + // Set points: 1 for golden task (excerpt contains 'GOLDEN'), 0 for regular user tasks. + $task['prpl_points'] = ( isset( $task['excerpt']['rendered'] ) && \str_contains( $task['excerpt']['rendered'], 'GOLDEN' ) ) ? 1 : 0; + $tasks[ $key ] = $task; + $modified_tasks[] = $task['id']; + } + return $tasks; + } } diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-add-aioseo-providers.php b/classes/suggested-tasks/providers/integrations/aioseo/class-add-aioseo-providers.php new file mode 100644 index 000000000..8b797d3bd --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-add-aioseo-providers.php @@ -0,0 +1,52 @@ +providers = [ + new Archive_Author(), + new Archive_Date(), + new Media_Pages(), + new Crawl_Settings_Feed_Authors(), + new Crawl_Settings_Feed_Comments(), + new Organization_Logo(), + ]; + + return \array_merge( + $providers, + $this->providers + ); + } +} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-aioseo-interactive-provider.php b/classes/suggested-tasks/providers/integrations/aioseo/class-aioseo-interactive-provider.php new file mode 100644 index 000000000..884a35ddc --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-aioseo-interactive-provider.php @@ -0,0 +1,30 @@ +is_task_relevant() ) { + return false; + } + + // Check if author archives are already disabled in AIOSEO. + // AIOSEO uses 'show' property - when false, archives are hidden from search results. + // Get a fresh copy of the options to avoid caching issues. + $show_value = \aioseo()->options->searchAppearance->archives->author->show; + + // If show is false (disabled), the task is complete (return false means don't add task). + // Using loose comparison to handle string/int/bool variations. + if ( ! $show_value ) { + return false; + } + + return true; + } + + /** + * Check if the task is still relevant. + * For example, we have a task to disable author archives if there is only one author. + * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. + * + * @return bool + */ + public function is_task_relevant() { + // If there is more than one author, we don't need to add the task. + return $this->get_data_collector()->collect() <= self::MINIMUM_AUTHOR_WITH_POSTS; + } + + /** + * Get the description. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'Your author archives are the same as your normal archives because you have only one author, so there\'s no reason for search engines to index these. That\'s why we suggest keeping them out of search results.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $this->print_submit_button( \__( 'Noindex the author archive', 'progress-planner' ) ); + } + + /** + * Handle the interactive task submit. + * + * This is only for interactive tasks that change non-core settings. + * The $_POST data is expected to be: + * - nonce: (string) The nonce. + * + * @return void + */ + public function handle_interactive_task_specific_submit() { + if ( ! \function_exists( 'aioseo' ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + \aioseo()->options->searchAppearance->archives->author->show = false; + + // Update the option. + \aioseo()->options->save(); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Noindex', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php new file mode 100644 index 000000000..86ed48d7b --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php @@ -0,0 +1,170 @@ +is_task_relevant() ) { + return false; + } + + // Check if date archives are already disabled in AIOSEO. + // AIOSEO uses 'show' property - when false, archives are hidden from search results. + $show_value = \aioseo()->options->searchAppearance->archives->date->show; + + // If show is false (disabled), the task is complete (return false means don't add task). + // Using loose comparison to handle string/int/bool variations. + if ( ! $show_value ) { + return false; + } + + return true; + } + + /** + * Check if the task is still relevant. + * For example, we have a task to disable author archives if there is only one author. + * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. + * + * @return bool + */ + public function is_task_relevant() { + // If the permalink structure includes %year%, %monthnum%, or %day%, we don't need to add the task. + $permalink_structure = \get_option( 'permalink_structure' ); + return \strpos( $permalink_structure, '%year%' ) === false + && \strpos( $permalink_structure, '%monthnum%' ) === false + && \strpos( $permalink_structure, '%day%' ) === false; + } + + /** + * Get the description. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'Date archives rarely add any real value for users or search engines, so there\'s no reason for search engines to index these. That\'s why we suggest keeping them out of search results.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $this->print_submit_button( \__( 'Noindex the date archive', 'progress-planner' ) ); + } + + /** + * Handle the interactive task submit. + * + * This is only for interactive tasks that change non-core settings. + * The $_POST data is expected to be: + * - nonce: (string) The nonce. + * + * @return void + */ + public function handle_interactive_task_specific_submit() { + if ( ! \function_exists( 'aioseo' ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + \aioseo()->options->searchAppearance->archives->date->show = false; + + // Update the option. + \aioseo()->options->save(); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Noindex', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php new file mode 100644 index 000000000..ddcfcc05a --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php @@ -0,0 +1,181 @@ +is_task_relevant() ) { + return false; + } + + // Check if crawl cleanup is enabled and author feeds are disabled. + $disable_author_feed = \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->authors; + + // Check if author feeds are already disabled. + if ( $disable_author_feed === false ) { + return false; + } + + return true; + } + + /** + * Check if the task is still relevant. + * For example, we have a task to disable author archives if there is only one author. + * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. + * + * @return bool + */ + public function is_task_relevant() { + // If there is more than one author, we don't need to add the task. + return $this->get_data_collector()->collect() <= self::MINIMUM_AUTHOR_WITH_POSTS; + } + + /** + * Get the description. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'The author feed on your site will be similar to your main feed if you have only one author, so there\'s no reason to have it.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $this->print_submit_button( \__( 'Disable author RSS feeds', 'progress-planner' ) ); + } + + /** + * Handle the interactive task submit. + * + * This is only for interactive tasks that change non-core settings. + * The $_POST data is expected to be: + * - nonce: (string) The nonce. + * + * @return void + */ + public function handle_interactive_task_specific_submit() { + if ( ! \function_exists( 'aioseo' ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->authors = false; + + // Update the option. + \aioseo()->options->save(); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php new file mode 100644 index 000000000..a0a42e7e5 --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php @@ -0,0 +1,157 @@ +options->searchAppearance->advanced->crawlCleanup->feeds->globalComments; // @phpstan-ignore-line + $disable_post_comment_feed = \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->postComments; // @phpstan-ignore-line + + // Check if comment feeds are already disabled. + if ( $disable_global_comment_feed === false && $disable_post_comment_feed === false ) { + return false; + } + + return true; + } + + /** + * Get the description. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'We suggest disabling both the global "recent comments feed" from your site as well as the "comments feed" per post that WordPress generates. These feeds are rarely used by real users, but get crawled a lot. They don\'t have any interesting information for crawlers, so removing them leads to less bot-traffic on your site without downsides.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $this->print_submit_button( \__( 'Disable comment RSS feeds', 'progress-planner' ) ); + } + + /** + * Handle the interactive task submit. + * + * This is only for interactive tasks that change non-core settings. + * The $_POST data is expected to be: + * - nonce: (string) The nonce. + * + * @return void + */ + public function handle_interactive_task_specific_submit() { + if ( ! \function_exists( 'aioseo' ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + // Global comment feed. + if ( \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->globalComments ) { // @phpstan-ignore-line + \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->globalComments = false; // @phpstan-ignore-line + } + + // Post comment feed. + if ( \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->postComments ) { // @phpstan-ignore-line + \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->postComments = false; // @phpstan-ignore-line + } + + // Update the option. + \aioseo()->options->save(); // @phpstan-ignore-line + + \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php b/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php new file mode 100644 index 000000000..c4a445c42 --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php @@ -0,0 +1,156 @@ + postTypes -> attachment -> redirectAttachmentUrls. + $redirect = \aioseo()->dynamicOptions->searchAppearance->postTypes->attachment->redirectAttachmentUrls; + + // The task is complete if redirectAttachmentUrls is set to 'attachment'. + // Possible values: 'disabled', 'attachment', or 'attachmentParent'. + // We recommend 'attachment' as it redirects to the attachment file itself. + if ( 'attachment' === $redirect ) { + return false; + } + + return true; + } + + /** + * Get the description. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'WordPress creates a "page" for every image you upload. These don\'t add any value but do cause more crawling on your site, so we suggest removing those.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + ?> + + \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + \aioseo()->dynamicOptions->searchAppearance->postTypes->attachment->redirectAttachmentUrls = 'attachment'; + + // Update the option. + \aioseo()->options->save(); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Redirect', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-organization-logo.php b/classes/suggested-tasks/providers/integrations/aioseo/class-organization-logo.php new file mode 100644 index 000000000..dfc1b4e41 --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-organization-logo.php @@ -0,0 +1,101 @@ +get_ui__branding()->get_url( 'https://prpl.fyi/aioseo-organization-logo' ); + } + + $options = \aioseo()->options->searchAppearance->global->schema; + $is_person = isset( $options->siteRepresents ) && 'person' === $options->siteRepresents; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + return $is_person + ? \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/aioseo-person-logo' ) + : \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/aioseo-organization-logo' ); + } + + /** + * Determine if the task should be added. + * + * @return bool + */ + public function should_add_task() { + // Check if AIOSEO is active. + if ( ! \function_exists( 'aioseo' ) ) { + return false; + } + + $represents = \aioseo()->options->searchAppearance->global->schema->siteRepresents; + + // Check if logo is already set. + if ( $represents === 'person' ) { + return false; + } + + // Check organization logo. + return \aioseo()->options->searchAppearance->global->schema->organizationLogo === ''; + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Set logo', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-add-yoast-providers.php b/classes/suggested-tasks/providers/integrations/yoast/class-add-yoast-providers.php index b7da1d28d..c4a96db06 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-add-yoast-providers.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-add-yoast-providers.php @@ -28,6 +28,8 @@ public function __construct() { \add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); \add_filter( 'progress_planner_exclude_public_taxonomies', [ $this, 'exclude_not_indexable_taxonomies' ] ); + + \add_filter( 'progress_planner_interactive_task_allowed_options', [ $this, 'add_interactive_task_allowed_options' ] ); } } @@ -121,4 +123,16 @@ public function exclude_not_indexable_taxonomies( $exclude_taxonomies ) { return $exclude_taxonomies; } + + /** + * Add the interactive task allowed options. + * + * @param array $allowed_options The allowed options. + * @return array + */ + public function add_interactive_task_allowed_options( $allowed_options ) { + $allowed_options[] = 'wpseo'; + $allowed_options[] = 'wpseo_titles'; + return $allowed_options; + } } diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php b/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php index b49ac83e5..367d98ac3 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php @@ -12,7 +12,7 @@ /** * Add task for Yoast SEO: disable the author archive. */ -class Archive_Author extends Yoast_Provider { +class Archive_Author extends Yoast_Interactive_Provider { /** * The minimum number of posts with a post format to add the task. @@ -28,6 +28,13 @@ class Archive_Author extends Yoast_Provider { */ protected const PROVIDER_ID = 'yoast-author-archive'; + /** + * The popover ID. + * + * @var string + */ + const POPOVER_ID = 'yoast-author-archive'; + /** * The data collector class name. * @@ -101,6 +108,26 @@ public function is_task_relevant() { return $this->get_data_collector()->collect() <= self::MINIMUM_AUTHOR_WITH_POSTS; } + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'When your site has only one author, the author archive is redundant and creates duplicate content issues. Disabling it prevents search engines from indexing the same content multiple times.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $this->print_submit_button( \__( 'Disable', 'progress-planner' ) ); + } + /** * Add task actions specific to this task. * @@ -112,7 +139,7 @@ public function is_task_relevant() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php b/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php index 802e8a664..48173c5f7 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php @@ -10,7 +10,7 @@ /** * Add task for Yoast SEO: disable the date archive. */ -class Archive_Date extends Yoast_Provider { +class Archive_Date extends Yoast_Interactive_Provider { /** * The provider ID. @@ -19,6 +19,13 @@ class Archive_Date extends Yoast_Provider { */ protected const PROVIDER_ID = 'yoast-date-archive'; + /** + * The popover ID. + * + * @var string + */ + const POPOVER_ID = 'yoast-date-archive'; + /** * The external link URL. * @@ -88,6 +95,26 @@ public function is_task_relevant() { && \strpos( $permalink_structure, '%day%' ) === false; } + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'Date archives are primarily useful for news sites and time-sensitive content. For most websites, they add unnecessary URLs that can dilute your SEO. Disable them unless your content is date-specific.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $this->print_submit_button( \__( 'Disable', 'progress-planner' ) ); + } + /** * Add task actions specific to this task. * @@ -99,7 +126,7 @@ public function is_task_relevant() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php b/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php index e4882d88a..0041f168c 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php @@ -12,7 +12,7 @@ /** * Add task for Yoast SEO: disable the format archives. */ -class Archive_Format extends Yoast_Provider { +class Archive_Format extends Yoast_Interactive_Provider { /** * The provider ID. @@ -21,6 +21,13 @@ class Archive_Format extends Yoast_Provider { */ protected const PROVIDER_ID = 'yoast-format-archive'; + /** + * The popover ID. + * + * @var string + */ + const POPOVER_ID = 'yoast-format-archive'; + /** * The minimum number of posts with a post format to add the task. * @@ -101,6 +108,26 @@ public function is_task_relevant() { return $this->get_data_collector()->collect() <= static::MINIMUM_POSTS_WITH_FORMAT; } + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'WordPress creates separate archives for each post format (standard, aside, gallery, etc.). Most sites don\'t use post formats, making these archives unnecessary. Disabling them reduces crawl waste and potential duplicate content.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $this->print_submit_button( \__( 'Disable', 'progress-planner' ) ); + } + /** * Add task actions specific to this task. * @@ -112,7 +139,7 @@ public function is_task_relevant() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-cornerstone-workout.php b/classes/suggested-tasks/providers/integrations/yoast/class-cornerstone-workout.php index dbae0ef05..729be9be9 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-cornerstone-workout.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-cornerstone-workout.php @@ -42,7 +42,7 @@ class Cornerstone_Workout extends Yoast_Provider { * * @var int */ - protected $priority = 90; + protected $priority = 20; /** * Whether the task is dismissable. diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php index d463b2467..9867a75bc 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php @@ -10,7 +10,7 @@ /** * Add task for Yoast SEO: Remove emoji scripts. */ -class Crawl_Settings_Emoji_Scripts extends Yoast_Provider { +class Crawl_Settings_Emoji_Scripts extends Yoast_Interactive_Provider { /** * The provider ID. @@ -19,6 +19,13 @@ class Crawl_Settings_Emoji_Scripts extends Yoast_Provider { */ protected const PROVIDER_ID = 'yoast-crawl-settings-emoji-scripts'; + /** + * The popover ID. + * + * @var string + */ + const POPOVER_ID = 'yoast-crawl-settings-emoji-scripts'; + /** * The external link URL. * @@ -80,6 +87,26 @@ public function should_add_task() { return true; } + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'WordPress loads extra JavaScript to support emojis in older browsers. Modern browsers (and most of your visitors) don\'t need this, so removing it improves your site\'s loading speed.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $this->print_submit_button( \__( 'Remove', 'progress-planner' ) ); + } + /** * Add task actions specific to this task. * @@ -91,7 +118,7 @@ public function should_add_task() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Remove', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Remove', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php index 3e9e38dee..ff550f62e 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php @@ -12,7 +12,7 @@ /** * Add task for Yoast SEO: Remove post authors feeds. */ -class Crawl_Settings_Feed_Authors extends Yoast_Provider { +class Crawl_Settings_Feed_Authors extends Yoast_Interactive_Provider { /** * The minimum number of posts with a post format to add the task. @@ -28,6 +28,13 @@ class Crawl_Settings_Feed_Authors extends Yoast_Provider { */ protected const PROVIDER_ID = 'yoast-crawl-settings-feed-authors'; + /** + * The popover ID. + * + * @var string + */ + const POPOVER_ID = 'yoast-crawl-settings-feed-authors'; + /** * The data collector class name. * @@ -112,6 +119,26 @@ public function is_task_relevant() { return $this->get_data_collector()->collect() <= self::MINIMUM_AUTHOR_WITH_POSTS; } + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'WordPress creates RSS feeds for each author (e.g., /author/john/feed/). Unless you\'re running a multi-author blog where readers want to follow specific writers, these feeds are unnecessary and create extra URLs for search engines to crawl.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $this->print_submit_button( \__( 'Remove', 'progress-planner' ) ); + } + /** * Add task actions specific to this task. * @@ -123,7 +150,7 @@ public function is_task_relevant() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Remove', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Remove', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php index 28642dcd5..95af3c30e 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php @@ -10,7 +10,7 @@ /** * Add task for Yoast SEO: Remove global comment feeds. */ -class Crawl_Settings_Feed_Global_Comments extends Yoast_Provider { +class Crawl_Settings_Feed_Global_Comments extends Yoast_Interactive_Provider { /** * The provider ID. @@ -19,6 +19,13 @@ class Crawl_Settings_Feed_Global_Comments extends Yoast_Provider { */ protected const PROVIDER_ID = 'yoast-crawl-settings-feed-global-comments'; + /** + * The popover ID. + * + * @var string + */ + const POPOVER_ID = 'yoast-crawl-settings-feed-global-comments'; + /** * The external link URL. * @@ -80,6 +87,26 @@ public function should_add_task() { return true; } + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'WordPress creates an RSS feed of all comments on your site. Unless you have active discussions that people want to follow, this feed is rarely used and creates an unnecessary URL.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $this->print_submit_button( \__( 'Remove', 'progress-planner' ) ); + } + /** * Add task actions specific to this task. * @@ -91,7 +118,7 @@ public function should_add_task() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Remove', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Remove', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-fix-orphaned-content.php b/classes/suggested-tasks/providers/integrations/yoast/class-fix-orphaned-content.php index 6c74ec637..cdaa76226 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-fix-orphaned-content.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-fix-orphaned-content.php @@ -89,7 +89,9 @@ protected function get_title_with_data( $task_data = [] ) { * @return string */ protected function get_url_with_data( $task_data = [] ) { - return \get_post( $task_data['target_post_id'] ) ? 'https://prpl.fyi/fix-orphaned-content' : ''; + return \get_post( $task_data['target_post_id'] ) + ? \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/fix-orphaned-content' ) + : ''; } /** @@ -230,7 +232,7 @@ public function exclude_completed_posts( $exclude_post_ids ) { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Learn more about internal linking', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Learn more about internal linking', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php b/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php index 7edfa0463..64371fa8e 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php @@ -10,7 +10,7 @@ /** * Add task for Yoast SEO: disable the media pages. */ -class Media_Pages extends Yoast_Provider { +class Media_Pages extends Yoast_Interactive_Provider { /** * The provider ID. @@ -19,6 +19,14 @@ class Media_Pages extends Yoast_Provider { */ protected const PROVIDER_ID = 'yoast-media-pages'; + /** + * The popover ID. + * + * @var string + */ + const POPOVER_ID = 'yoast-media-pages'; + + /** * The external link URL. * @@ -73,6 +81,26 @@ public function should_add_task() { return \YoastSEO()->helpers->options->get( 'disable-attachment' ) !== true; // @phpstan-ignore-line property.nonObject } + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'WordPress creates individual pages for every image you upload. These attachment pages rarely provide value and can cause thin content issues for SEO. Disable them unless you\'re running a photography or art portfolio site where the attachment pages themselves are important.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + $this->print_submit_button( \__( 'Disable', 'progress-planner' ) ); + } + /** * Add task actions specific to this task. * @@ -84,7 +112,7 @@ public function should_add_task() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php b/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php index 54708e9d4..e32e20352 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php @@ -10,7 +10,7 @@ /** * Add task for Yoast SEO: set your organization logo. */ -class Organization_Logo extends Yoast_Provider { +class Organization_Logo extends Yoast_Interactive_Provider { /** * The provider ID. @@ -19,6 +19,13 @@ class Organization_Logo extends Yoast_Provider { */ protected const PROVIDER_ID = 'yoast-organization-logo'; + /** + * The popover ID. + * + * @var string + */ + const POPOVER_ID = 'yoast-organization-logo'; + /** * Yoast SEO instance. * @@ -30,9 +37,19 @@ class Organization_Logo extends Yoast_Provider { * Constructor. */ public function __construct() { + parent::__construct(); $this->yoast_seo = \YoastSEO(); } + /** + * Check if the site is in person mode. + * + * @return bool + */ + protected function is_person_mode() { + return 'person' === $this->yoast_seo->helpers->options->get( 'company_or_person', 'company' ); // @phpstan-ignore-line property.nonObject + } + /** * Get the task URL. * @@ -48,9 +65,9 @@ protected function get_url() { * @return string */ protected function get_title() { - return $this->yoast_seo->helpers->options->get( 'company_or_person', 'company' ) !== 'person' // @phpstan-ignore-line property.nonObject - ? \esc_html__( 'Yoast SEO: set your organization logo', 'progress-planner' ) - : \esc_html__( 'Yoast SEO: set your person logo', 'progress-planner' ); + return $this->is_person_mode() + ? \esc_html__( 'Yoast SEO: set your person logo', 'progress-planner' ) + : \esc_html__( 'Yoast SEO: set your organization logo', 'progress-planner' ); } /** @@ -59,9 +76,9 @@ protected function get_title() { * @return string */ public function get_external_link_url() { - return $this->yoast_seo->helpers->options->get( 'company_or_person', 'company' ) !== 'person' // @phpstan-ignore-line property.nonObject - ? 'https://prpl.fyi/yoast-person-logo' - : 'https://prpl.fyi/yoast-organization-logo'; + return $this->is_person_mode() + ? \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/yoast-organization-logo' ) + : \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/yoast-person-logo' ); } /** @@ -98,15 +115,27 @@ public function get_focus_tasks() { * @return bool */ public function should_add_task() { - // If the site is for a person, and the person logo is already set, we don't need to add the task. - if ( $this->yoast_seo->helpers->options->get( 'company_or_person', 'company' ) === 'company' // @phpstan-ignore-line property.nonObject + + // Check if the site logo is set, Yoast SEO uses it as a fallback. + $site_logo_id = \get_option( 'site_logo' ); + if ( ! $site_logo_id ) { + $site_logo_id = \get_theme_mod( 'custom_logo', false ); + } + + // If the site logo is set, we don't need to add the task. + if ( (int) $site_logo_id ) { + return false; + } + + // If the site is for a company, and the company logo is already set, we don't need to add the task. + if ( ! $this->is_person_mode() && $this->yoast_seo->helpers->options->get( 'company_logo' ) // @phpstan-ignore-line property.nonObject ) { return false; } - // If the site is for a person, and the organization logo is already set, we don't need to add the task. - if ( $this->yoast_seo->helpers->options->get( 'company_or_person', 'company' ) === 'person' // @phpstan-ignore-line property.nonObject + // If the site is for a person, and the person logo is already set, we don't need to add the task. + if ( $this->is_person_mode() && $this->yoast_seo->helpers->options->get( 'person_logo' ) // @phpstan-ignore-line property.nonObject ) { return false; @@ -115,6 +144,81 @@ public function should_add_task() { return true; } + /** + * Get the popover instructions. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + if ( $this->is_person_mode() ) { + \printf( + /* translators: %s: "Read more" link. */ + \esc_html__( 'To make Yoast SEO output the correct Schema, you need to set your person logo in the Yoast SEO settings. %s.', 'progress-planner' ), + '' . \esc_html__( 'Read more', 'progress-planner' ) . '' + ); + } else { + \printf( + /* translators: %s: "Read more" link. */ + \esc_html__( 'To make Yoast SEO output the correct Schema, you need to set your organization logo in the Yoast SEO settings. %s.', 'progress-planner' ), + '' . \esc_html__( 'Read more', 'progress-planner' ) . '' + ); + } + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + // Enqueue media scripts. + \wp_enqueue_media(); + + $organization_logo_id = $this->is_person_mode() + ? $this->yoast_seo->helpers->options->get( 'person_logo' ) // @phpstan-ignore-line property.nonObject + : $this->yoast_seo->helpers->options->get( 'company_logo' ); // @phpstan-ignore-line property.nonObject + ?> +
+ + 'max-width: 150px; height: auto; border-radius: 4px; border: 1px solid #ddd;' ] ); ?> + + + +
+ + +
+ +
+ 'prplYoastOrganizationLogo', + 'data' => [ + 'mediaTitle' => $this->is_person_mode() + ? \esc_html__( 'Choose Person Logo', 'progress-planner' ) + : \esc_html__( 'Choose Organization Logo', 'progress-planner' ), + 'mediaButtonText' => $this->is_person_mode() + ? \esc_html__( 'Use as Person Logo', 'progress-planner' ) + : \esc_html__( 'Use as Organization Logo', 'progress-planner' ), + 'companyOrPerson' => $this->is_person_mode() ? 'person' : 'company', + ], + ]; + } + /** * Add task actions specific to this task. * @@ -126,7 +230,7 @@ public function should_add_task() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Set logo', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Set logo', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-orphaned-content-workout.php b/classes/suggested-tasks/providers/integrations/yoast/class-orphaned-content-workout.php index 9141293c8..df5eeb3f0 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-orphaned-content-workout.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-orphaned-content-workout.php @@ -42,7 +42,7 @@ class Orphaned_Content_Workout extends Yoast_Provider { * * @var int */ - protected $priority = 90; + protected $priority = 20; /** * Whether the task is dismissable. diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-yoast-interactive-provider.php b/classes/suggested-tasks/providers/integrations/yoast/class-yoast-interactive-provider.php new file mode 100644 index 000000000..a2d2b0ea7 --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/yoast/class-yoast-interactive-provider.php @@ -0,0 +1,39 @@ + + */ + const BRANDING_IDS = [ + 'default' => 0, + ]; + + /** + * Constructor. + */ + public function __construct() { + \add_filter( 'progress_planner_admin_widgets', [ $this, 'filter_widgets' ] ); + } + + /** + * Get the branding ID. + * + * @return int + */ + public function get_branding_id(): int { + // Check for placeholder demo cookie. + if ( \defined( '\IS_PLAYGROUND_PREVIEW' ) + && \constant( '\IS_PLAYGROUND_PREVIEW' ) === true + && isset( $_COOKIE['prpl_placeholder_demo'] ) + && '1' === $_COOKIE['prpl_placeholder_demo'] + ) { + return 5938; + } + + // Get branding ID depending on the host, agency etc. + if ( \defined( 'PROGRESS_PLANNER_BRANDING_ID' ) ) { + return \constant( 'PROGRESS_PLANNER_BRANDING_ID' ); + } + if ( isset( $_GET['pp_branding_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return (int) $_GET['pp_branding_id']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + + $hostname = (string) \gethostname(); + if ( \str_contains( $hostname, 'siteground' ) ) { + $hostname = 'siteground'; + } elseif ( \str_contains( $hostname, 'wpengine' ) ) { + $hostname = 'wpengine'; + } elseif ( \str_contains( $hostname, 'bluehost' ) ) { + $hostname = 'bluehost'; + } elseif ( \str_contains( $hostname, 'godaddy' ) ) { + $hostname = 'godaddy'; + } + + return isset( self::BRANDING_IDS[ $hostname ] ) + ? self::BRANDING_IDS[ $hostname ] + : self::BRANDING_IDS['default']; + } + + /** + * Get the api data. + * + * @return array + */ + public function get_api_data(): array { + if ( 0 === $this->get_branding_id() ) { + return []; + } + + $response = $this->get_remote_data( + \progress_planner()->get_remote_server_root_url() . '/wp-json/wp/v2/pp-branding/' . $this->get_branding_id() + ); + if ( ! $response ) { + return []; + } + + return \is_array( $response ) ? $response : \json_decode( $response, true ); + } + + /** + * Print the logo. + * + * @return void + */ + public function the_logo(): void { + // Get the logo ID from the API data. + if ( ! empty( $this->get_api_data() ) + && isset( $this->get_api_data()['logo'] ) + && ! empty( $this->get_api_data()['logo'] ) + ) { + $logo_id = $this->get_api_data()['logo']; + // Get the logo URL. + $response = $this->get_remote_data( \progress_planner()->get_remote_server_root_url() . '/wp-json/wp/v2/media/' . $logo_id ); + if ( $response ) { + $media = \json_decode( $response, true ); + if ( isset( $media['source_url'] ) ) { + echo 'Logo'; + return; + } + } + } + + \progress_planner()->the_asset( 'images/logo_progress_planner.svg' ); + } + + /** + * Get the custom CSS. + * + * @return string + */ + public function get_custom_css(): string { + return empty( $this->get_api_data() ) || empty( $this->get_api_data()['custom_css'] ) + ? '' + : $this->get_api_data()['custom_css']; + } + + /** + * Get the admin-menu icon. + * + * @return string + */ + public function get_admin_menu_icon(): string { + $icon = 'data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIGFyaWEtaGlkZGVuPSJ0cnVlIiBmb2N1c2FibGU9ImZhbHNlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzNjggNTAwIj48cGF0aCBmaWxsPSIjMzgyOTZkIiBkPSJNMjE3LjQ2IDE3Mi45YzMuMjEuMTIgNS45NyAxLjc0IDcuNzMgNC4xNS0xLjg3LTEwLjI0LTEwLjY0LTE4LjE3LTIxLjQ4LTE4LjU2LTEyLjUyLS40NS0yMy4wMyA5LjMzLTIzLjQ4IDIxLjg1LS40NSAxMi41MiA5LjMzIDIzLjAzIDIxLjg1IDIzLjQ4IDkuNC4zNCAxNy42Ny01LjEgMjEuNC0xMy4xMy0xLjgzIDEuNTEtNC4xOCAyLjQyLTYuNzQgMi4zMy01LjU1LS4yLTkuODktNC44Ni05LjY5LTEwLjQxLjItNS41NSA0Ljg2LTkuODkgMTAuNDEtOS42OVpNMjQxLjUxIDMwNS44NGMuNTggMS45MiAxLjEzIDMuODYgMS43MyA1Ljc3IDE0LjA0IDQ0Ljk3IDMzLjk0IDg4Ljc1IDU2LjQyIDEyNC4yN2w2Ny43NS0xMzAuMDRoLTEyNS45Wk0yOTcuOTYgMjA1Ljk3YzEyLjEyLTQuNSAyMy41NC03LjE4IDMzLjY0LTguOTYtMjIuNTEtMjIuMjctNjEuMjQtMjcuMDYtNjEuNDctMjcuMDkgMS4yNyA2LjE3LjU4IDE1LjgtMi40NCAyNi40Ni0zLjMgMTEuNjYtOS4zOCAyNC41NC0xOC43IDM1LjQ4LTMuNDUgNC4wNi03LjM2IDcuODMtMTEuNzMgMTEuMTloLjA3di0uMDFjLjE2LjYyLjM4IDEuMi41OCAxLjc5IDIuNzQgOC4yNyA4LjYxIDEzLjc0IDE0LjkzIDE3LjE0IDYuNDggMy40OSAxMy4zNyA0LjgzIDE3LjY4IDQuODMgNi40IDAgMTEuODgtMy43OSAxNC40My05LjIyLjk3LTIuMDYgMS41NS00LjMzIDEuNTUtNi43NiAwLTMuODUtMS40Mi03LjM0LTMuNjktMTAuMS0xLjkyLTIuMzMtNC40Ni00LjA4LTcuMzktNS4wM2w0NC44Mi04LjY1Yy02LjYzLTYuMTItMTQuNzItMTEuNTktMjIuNzMtMTYuMjMtMS45Ny0xLjE0LTEuNjktNC4wNS40NS00Ljg0WiIvPjxwYXRoIGZpbGw9IiNmYWEzMTAiIGQ9Ik0yODEuMzcgNDU4LjM3Yy0yNS43OS0zOC44NC00OC42OC04OC4wNC02NC40NS0xMzguNTQtMS40NS00LjYzLTIuODMtOS4zMS00LjE3LTEzLjk5LTEuMTItMy45NC0yLjIyLTcuODgtMy4yNS0xMS44LTIuMDktNy45Mi05LjI4LTEzLjQ2LTE3LjQ4LTEzLjQ2aC0yNy45NWMtOC4yIDAtMTUuMzkgNS41My0xNy40OCAxMy40NS0yLjI4IDguNjUtNC43OCAxNy4zMi03LjQyIDI1Ljc5LTE1Ljc3IDUwLjUtMzguNjUgOTkuNy02NC40NSAxMzguNTQtNC4wMSA2LjAzLTEuNzggMTEuNjMtLjY0IDEzLjc2IDIuNCA0LjQ3IDYuODYgNy4xNCAxMS45NCA3LjE0aDY2LjAxbDMuOTcgNi45MmM0LjU0IDcuOSAxMi45OSAxMi44MSAyMi4wNSAxMi44MXMxNy41MS00LjkxIDIyLjA2LTEyLjgxbDMuOTgtNi45Mmg2NmMzLjIyIDAgNi4xOS0xLjA4IDguNTUtMy4wMiAxLjM1LTEuMTEgMi41MS0yLjQ5IDMuMzgtNC4xMy41Ny0xLjA3IDEuNDItMy4wMiAxLjYxLTUuNDYuMTktMi40MS0uMjYtNS4zMS0yLjI1LTguMzFaIi8+PHBhdGggZmlsbD0iIzM4Mjk2ZCIgZD0iTTI5NS43IDc2LjA2Yy03LjU0LTEyLjA1LTMyLjM4IDEtNTkuNTQgMi44Ni0xNS4wNCAxLjAzLTM3LjA1LTExMC42My03MS43Ny01Ni45OS0zOS41NiA2MS4xLTc5LjEyLTQ0LjY4LTg4LjY2LTE1LjgzLTIxLjExIDQzLjI3IDI1LjE1IDg0LjYxIDI1LjE1IDg0LjYxcy0xMi44NCA3LjkyLTIwLjYzIDEzLjkzYy01LjQ3IDQuMTctMTAuODIgOC42NS0xNi4wMyAxMy41MS0yMC40NSAxOS4wMy0zNi4wNCA0MC4zMi00Ni43NyA2My44NkM2LjcyIDIwNS41NSAxLjExIDIyOS41OS42MiAyNTQuMTVjLS40OSAyNC41NiA0LjAxIDQ5LjEgMTMuNTQgNzMuNjMgOS41MiAyNC41MyAyNC4xNyA0Ny40MiA0My45NSA2OC42OCA0LjAyIDQuMzIgOC4xMiA4LjQxIDEyLjMxIDEyLjMgNC4xLTYuMzEgNy45Ny0xMi43NCAxMS42NC0xOS4yNiA0LjM5LTcuOCA4LjUtMTUuNzIgMTIuMjUtMjMuNzgtLjMzLS4zNS0uNjYtLjY5LS45OS0xLjAzLS4xNy0uMTgtLjM0LS4zNS0uNTEtLjUzLTE1LjUzLTE2LjY5LTI3LjE3LTM0LjU5LTM0LjkzLTUzLjcyLTcuNzctMTkuMTMtMTEuNS0zOC4yNS0xMS4yLTU3LjM2LjI5LTE5LjEgNC40Ny0zNy42OCAxMi41My01NS43MiA4LjA2LTE4LjA1IDIwLjAyLTM0LjQ1IDM1LjktNDkuMjIgMTMuOTktMTMuMDIgMjguODQtMjIuODMgNDQuNTUtMjkuNDEgMTUuNy02LjU5IDMxLjYzLTkuOTggNDcuNzYtMTAuMTggOS4wNS0uMTEgMTkuMTEgMS4xNSAyOS41MSA0LjUgMTAuMzIgNC4yNyAxOS4yMiA5LjQ0IDI2LjYzIDE1LjM1IDEwLjE5IDguMTMgMTcuNjEgMTcuNjUgMjIuMjIgMjguMSAxLjkxIDQuMzIgMy4zNyA4LjggNC4zMiAxMy40MSAxNi4yNy0yOC4yNyAzNi43NS03NS45NiAyNS41Ny05My44M1oiLz48L3N2Zz4='; + + $admin_menu_icon_id = empty( $this->get_api_data() ) || empty( $this->get_api_data()['admin_menu_icon'] ) + ? '' + : $this->get_api_data()['admin_menu_icon']; + + if ( $admin_menu_icon_id ) { + // Get the logo URL. + $response = $this->get_remote_data( \progress_planner()->get_remote_server_root_url() . '/wp-json/wp/v2/media/' . $admin_menu_icon_id ); + if ( $response ) { + $media = \json_decode( $response, true ); + if ( \is_array( $media ) && \array_key_exists( 'source_url', $media ) ) { + // Get the content of the image. + $content = $this->get_remote_data( $media['source_url'] ); + if ( $content ) { + $icon = 'data:image/svg+xml;base64,' . \base64_encode( $content ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + } + } + } + } + + return $icon; + } + + /** + * Get the admin-menu name. + * + * @return string + */ + public function get_admin_menu_name(): string { + return empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['admin_menu_name'] ) + ? 'Progress Planner' + : $this->get_api_data()['admin_menu_name']; + } + + /** + * Get the admin-submenu name. + * + * @return string + */ + public function get_admin_submenu_name(): string { + return empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['admin_submenu_name'] ) + ? $this->get_admin_menu_name() + : $this->get_api_data()['admin_submenu_name']; + } + + /** + * Get the Ravi name. + * + * @return string + */ + public function get_ravi_name(): string { + return empty( $this->get_api_data() ) || empty( $this->get_api_data()['ravis_name'] ) + ? 'Ravi' + : $this->get_api_data()['ravis_name']; + } + + /** + * Get data from a remote URL, cached. + * + * @param string $url The URL to get the data from. + * @return mixed + */ + public function get_remote_data( $url ) { + $cache_key = \md5( $url ); + $cached = \progress_planner()->get_utils__cache()->get( $cache_key ); + if ( $cached ) { + return $cached; + } + + $response = \wp_remote_get( $url ); + if ( \is_wp_error( $response ) ) { + \progress_planner()->get_utils__cache()->set( $cache_key, [], 5 * MINUTE_IN_SECONDS ); + return false; + } + + if ( 200 !== (int) \wp_remote_retrieve_response_code( $response ) ) { + \progress_planner()->get_utils__cache()->set( $cache_key, [], 5 * MINUTE_IN_SECONDS ); + return false; + } + + $body = \wp_remote_retrieve_body( $response ); + + \progress_planner()->get_utils__cache()->set( $cache_key, $body, WEEK_IN_SECONDS ); + + return $body; + } + + /** + * Filter the widgets to be displayed on the admin page. + * + * @param array<\Progress_Planner\Admin\Widgets\Widget> $widgets The widgets. + * + * @return array<\Progress_Planner\Admin\Widgets\Widget> + */ + public function filter_widgets( $widgets ) { + if ( empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['papers'] ) ) { + return $widgets; + } + + $show_papers = $this->get_api_data()['papers']; + if ( ! $show_papers ) { + return $widgets; + } + + return \array_filter( $widgets, fn( $widget ) => \in_array( $widget->get_id(), $show_papers, true ) ); + } + + /** + * Get the widget title. + * + * @param string $widget_id The widget ID. + * @param string $default_value The default value. + * + * @return string + */ + public function get_widget_title( $widget_id, $default_value = '' ) { + if ( empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['widget_titles'] ) ) { + return $default_value; + } + + foreach ( $this->get_api_data()['widget_titles'] as $widget ) { + if ( $widget['widget_id'] === $widget_id && isset( $widget['title'] ) ) { + return $widget['title']; + } + } + + return $default_value; + } + + /** + * Get the blog-feed URL. + * + * @return string + */ + public function get_blog_feed_url(): string { + return empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['blog_feed_url'] ) + ? \progress_planner()->get_remote_server_root_url() + : $this->get_api_data()['blog_feed_url']; + } + + /** + * Get a URL from the API data. + * + * @param string $default_url The default value. + * + * @return string + */ + public function get_url( $default_url = '' ) { + $api_data = $this->get_api_data(); + if ( empty( $api_data ) || ! isset( $api_data['links'] ) ) { + return $default_url; + } + + foreach ( $api_data['links'] as $link ) { + if ( $link['original_url'] === $default_url ) { + return $link['url']; + } + } + + return $default_url; + } + + /** + * Get the recommended SEO plugin slug. + * + * @return string + */ + public function get_seo_plugin_recommendation_slug(): string { + return empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['seo_plugin_recommendation_slug'] ) + ? 'wordpress-seo' + : $this->get_api_data()['seo_plugin_recommendation_slug']; + } +} diff --git a/classes/ui/class-chart.php b/classes/ui/class-chart.php index 17f8e44b6..71f2b3a05 100644 --- a/classes/ui/class-chart.php +++ b/classes/ui/class-chart.php @@ -48,19 +48,13 @@ public function get_chart_data( $args = [] ) { $args = \wp_parse_args( $args, [ - 'items_callback' => function ( $start_date, $end_date ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter - return 0; - }, + 'items_callback' => fn( $start_date, $end_date ) => 0, // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter 'filter_results' => null, 'dates_params' => [], 'normalized' => false, - 'color' => function () { - return '#534786'; - }, + 'color' => fn() => '#534786', // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter - 'count_callback' => function ( $activities, $date = null ) { - return \count( $activities ); - }, + 'count_callback' => fn( $activities, $date = null ) => \count( $activities ), 'max' => null, 'type' => 'line', 'return_data' => [ 'label', 'score', 'color' ], diff --git a/classes/update/class-update-130.php b/classes/update/class-update-130.php index 5c4095d8c..a621af36e 100644 --- a/classes/update/class-update-130.php +++ b/classes/update/class-update-130.php @@ -106,8 +106,8 @@ private function restore_completed_tasks() { // Generate the data from the task ID. $data = $this->get_data_from_task_id( $activity->data_id ); - // Don't import back tasks that don't have a provider_id or category. - if ( empty( $data['provider_id'] ) || empty( $data['category'] ) ) { + // Don't import back tasks that don't have a provider_id. + if ( empty( $data['provider_id'] ) ) { continue; } @@ -193,7 +193,6 @@ private function handle_legacy_review_post_tasks( $task_object ) { 'post_id' => $parts[2], 'date' => $parts[3], 'provider_id' => $task_provider ? $task_provider->get_provider_id() : 'review-post', - 'category' => $task_provider ? $task_provider->get_provider_category() : 'content-update', ]; $task_object->set_data( $data ); @@ -213,7 +212,6 @@ private function handle_legacy_yoast_and_comment_hacks_tasks( $task_object ) { [ 'task_id' => $task_object->get_task_id(), 'provider_id' => $task_object->get_task_id(), - 'category' => 'configuration', ] ); diff --git a/classes/update/class-update-190.php b/classes/update/class-update-190.php new file mode 100644 index 000000000..92d2c0d51 --- /dev/null +++ b/classes/update/class-update-190.php @@ -0,0 +1,249 @@ +migrate_recommendations_slugs(); + + // Migrate the golden task. + $this->migrate_golden_todo_task(); + + // Clean up old category taxonomy data. + // This needs to run on init hook so we can register the taxonomy. + \add_action( 'init', [ $this, 'cleanup_category_taxonomy' ], 0 ); + + // Migrate task priorities to new priority system. + // This needs to run after tasks_manager is initialized (priority 99 on init hook). + // So we hook it to run at priority 100. + \add_action( 'init', [ $this, 'migrate_task_priorities' ], 100 ); + } + + /** + * Migrate the recommendations slugs. + * + * @return void + */ + private function migrate_recommendations_slugs() { + // Get all recommendations. + $recommendations = \progress_planner()->get_suggested_tasks_db()->get(); + foreach ( $recommendations as $recommendation ) { + // Get the `prpl_task_id` meta. + $prpl_task_id = \get_post_meta( $recommendation->ID, 'prpl_task_id', true ); + if ( ! $prpl_task_id ) { + continue; + } + + // Get the target slug. + $target_slug = \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $prpl_task_id ); + + // Check if there are any existing posts with the same slug. + $existing_posts = \get_posts( + [ + 'post_type' => 'prpl_recommendations', + 'name' => $target_slug, + ] + ); + if ( ! empty( $existing_posts ) && $existing_posts[0]->ID !== $recommendation->ID ) { + // Delete the existing post (but not if it's the current one). + \wp_delete_post( $existing_posts[0]->ID, true ); + } + + // Only update if the slug is different from the current slug. + if ( $recommendation->post_name !== $target_slug ) { + \wp_update_post( + [ + 'ID' => $recommendation->ID, + 'post_name' => $target_slug, + ] + ); + } + } + } + + /** + * Migrate the golden task. + * + * @return void + */ + private function migrate_golden_todo_task() { + // Get all user tasks. + $tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'provider_id' => 'user' ] ); + + // Loop through tasks and update the `post_excerpt` if the `prpl_points` meta is set to 1. + global $wpdb; + foreach ( $tasks as $task ) { + // Get the `prpl_points` meta. + // We'll be getting the value directly from the database since the post-meta is no longer used. + $points = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + 'SELECT meta_value FROM %i WHERE post_id = %d AND meta_key = %s', + $wpdb->postmeta, // @phpstan-ignore-line property.nonObject + $task->ID, + 'prpl_points' + ) + ); + if ( 1 === (int) $points ) { + \progress_planner()->get_suggested_tasks_db()->update_recommendation( + $task->ID, + [ 'post_excerpt' => 'GOLDEN' ] + ); + } + } + } + + /** + * Clean up old category taxonomy data. + * + * The prpl_recommendations_category taxonomy has been removed in v1.9.0. + * This method temporarily registers the taxonomy, deletes all its terms, + * then unregisters it. + * + * @return void + */ + public function cleanup_category_taxonomy() { + // Temporarily register the old taxonomy so we can use WordPress functions. + \register_taxonomy( + 'prpl_recommendations_category', + [ 'prpl_recommendations' ], + [ + 'public' => false, + 'hierarchical' => false, + ] + ); + + // Get all terms in this taxonomy. + $terms = \get_terms( + [ + 'taxonomy' => 'prpl_recommendations_category', + 'hide_empty' => false, + ] + ); + + // Delete each term. WordPress will handle all related cleanup automatically. + if ( ! \is_wp_error( $terms ) && ! empty( $terms ) ) { + foreach ( $terms as $term ) { + \wp_delete_term( $term->term_id, 'prpl_recommendations_category' ); + } + } + + // Unregister the taxonomy. + \unregister_taxonomy( 'prpl_recommendations_category' ); + + // Clear WordPress caches. + \wp_cache_flush(); + } + + /** + * Migrate task priorities to the new priority system. + * + * Updates the menu_order of existing tasks to match their provider's current priority value. + * This ensures that tasks display in the correct order after priority constants were introduced. + * + * @return void + */ + public function migrate_task_priorities() { + // Map of provider_id => new priority value. + // Ordered by priority (low number = high priority). + // This is hardcoded to avoid dependency on tasks_manager being initialized. + $priority_map = [ + 'core-siteicon' => 1, + 'core-blogdescription' => 2, + 'core-permalink-structure' => 3, + 'sending-email' => 4, + 'search-engine-visibility' => 5, + 'select-timezone' => 6, + 'set-date-format' => 7, + 'select-locale' => 8, + 'disable-comments' => 9, + 'settings-saved' => 10, + 'wp-debug-display' => 10, + 'review-post' => 10, + 'disable-comment-pagination' => 10, + 'sample-page' => 14, + 'hello-world' => 15, + 'update-core' => 20, + 'seo-plugin' => 20, + 'yoast-cornerstone-workout' => 20, + 'yoast-orphaned-content-workout' => 20, + 'yoast-date-archive' => 20, + 'yoast-format-archive' => 20, + 'yoast-author-archive' => 20, + 'yoast-media-pages' => 20, + 'yoast-organization-logo' => 20, + 'yoast-crawl-settings-feed-authors' => 20, + 'yoast-crawl-settings-feed-global-comments' => 20, + 'yoast-crawl-settings-emoji-scripts' => 20, + 'yoast-fix-orphaned-content' => 20, + 'aioseo-media-pages' => 20, + 'aioseo-organization-logo' => 20, + 'aioseo-crawl-settings-feed-comments' => 20, + 'aioseo-author-archive' => 20, + 'aioseo-crawl-settings-feed-authors' => 20, + 'aioseo-date-archive' => 20, + 'php-version' => 25, + 'fewer-tags' => 32, + 'collaborator' => 50, + 'user' => 50, + 'create-post' => 50, + 'unpublished-content' => 55, + 'remove-inactive-plugins' => 60, + 'rename-uncategorized-category' => 60, + 'remove-terms-without-posts' => 60, + 'set-valuable-post-types' => 70, + 'update-term-description' => 80, + ]; + + // Loop through each provider and update its tasks. + foreach ( $priority_map as $provider_id => $priority ) { + // Get all tasks for this provider. + $tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'provider_id' => $provider_id, + 'post_status' => [ 'publish', 'trash', 'draft', 'future', 'pending' ], + ] + ); + + // Update the menu_order for each task. + foreach ( $tasks as $task ) { + // Refresh the task data to ensure we have the latest menu_order value. + $refreshed_task = \get_post( $task->ID ); + + // Skip if the post no longer exists. + if ( ! $refreshed_task ) { + continue; + } + + // Only update if the menu_order is different from the current priority. + if ( (int) $refreshed_task->menu_order !== $priority ) { + \progress_planner()->get_suggested_tasks_db()->update_recommendation( + $refreshed_task->ID, + [ 'menu_order' => $priority ] + ); + } + } + } + + // Clear the tasks cache to ensure fresh data is loaded. + \wp_cache_flush_group( \Progress_Planner\Suggested_Tasks_DB::GET_TASKS_CACHE_GROUP ); + } +} diff --git a/classes/utils/class-color-customizer.php b/classes/utils/class-color-customizer.php new file mode 100644 index 000000000..6097b86fb --- /dev/null +++ b/classes/utils/class-color-customizer.php @@ -0,0 +1,574 @@ +register_hooks(); + } + + /** + * Register the hooks. + * + * @return void + */ + private function register_hooks() { + \add_action( 'admin_menu', [ $this, 'add_page' ] ); + \add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); + \add_action( 'admin_init', [ $this, 'handle_form_submission' ] ); + \add_action( 'admin_head', [ $this, 'add_inline_css' ] ); + } + + /** + * Add the admin page (hidden from menu). + * + * @return void + */ + public function add_page() { + // Add the page but don't show it in the menu. + \add_submenu_page( + 'progress-planner', + 'Color Customizer', + 'Color Customizer', + 'manage_options', + 'progress-planner-color-customizer', + [ $this, 'render_page' ] + ); + } + + + /** + * Enqueue scripts and styles. + * + * @param string $hook The current admin page. + * + * @return void + */ + public function enqueue_assets( $hook ) { + if ( 'progress-planner_page_progress-planner-color-customizer' !== $hook ) { + return; + } + + // Enqueue the variables-color.css first. + \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/variables-color' ); + \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/admin' ); + + // Enqueue the color customizer JavaScript. + \progress_planner()->get_admin__enqueue()->enqueue_script( 'color-customizer' ); + + // Add custom CSS for the color picker page. + \wp_add_inline_style( 'progress-planner/admin', $this->get_customizer_css() ); + } + + /** + * Handle form submission. + * + * @return void + */ + public function handle_form_submission() { + if ( ! isset( $_POST['progress_planner_color_customizer_nonce'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + return; + } + + $nonce = \sanitize_text_field( \wp_unslash( $_POST['progress_planner_color_customizer_nonce'] ) ); + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $nonce ) ), 'progress_planner_color_customizer' ) ) { + return; + } + + if ( ! \current_user_can( 'manage_options' ) ) { + return; + } + + $action = \sanitize_text_field( \wp_unslash( $_POST['action'] ?? '' ) ); + + switch ( $action ) { + case 'save_colors': + $this->save_colors(); + break; + case 'reset_colors': + $this->reset_colors(); + break; + case 'export_colors': + $this->export_colors(); + break; + case 'import_colors': + $this->import_colors(); + break; + } + } + + /** + * Save color settings. + * + * @return void + */ + private function save_colors() { + $colors = []; + $color_variables = $this->get_color_variables(); + + foreach ( $color_variables as $section => $variables ) { + foreach ( $variables as $variable => $default_value ) { + $key = "color_{$variable}"; + if ( isset( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + $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; + } + } + } + } + + \update_option( self::OPTION_NAME, $colors ); + \add_action( + 'admin_notices', + function () { + echo '

Colors saved successfully!

'; + } + ); + } + + /** + * Reset color settings. + * + * @return void + */ + private function reset_colors() { + \delete_option( self::OPTION_NAME ); + \add_action( + 'admin_notices', + function () { + echo '

Colors reset to defaults!

'; + } + ); + } + + /** + * Export color settings. + * + * @return void + */ + private function export_colors() { + $colors = \get_option( self::OPTION_NAME, [] ); + $export_data = [ + 'version' => '1.0', + 'colors' => $colors, + 'exported_at' => \current_time( 'mysql' ), + ]; + + \header( 'Content-Type: application/json' ); + \header( 'Content-Disposition: attachment; filename="progress-planner-colors.json"' ); + echo \wp_json_encode( $export_data, JSON_PRETTY_PRINT ); + exit; + } + + /** + * Import color settings. + * + * @return void + */ + private function import_colors() { + if ( ! isset( $_FILES['color_file'] ) || $_FILES['color_file']['error'] !== UPLOAD_ERR_OK ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated + \add_action( + 'admin_notices', + function () { + echo '

Error uploading file!

'; + } + ); + return; + } + + $file_content = \file_get_contents( $_FILES['color_file']['tmp_name'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $import_data = \json_decode( $file_content, true ); + + if ( ! $import_data || ! isset( $import_data['colors'] ) ) { + \add_action( + 'admin_notices', + function () { + echo '

Invalid file format!

'; + } + ); + return; + } + + \update_option( self::OPTION_NAME, $import_data['colors'] ); + \add_action( + 'admin_notices', + function () { + echo '

Colors imported successfully!

'; + } + ); + } + + /** + * Add inline CSS to override default colors. + * + * @return void + */ + public function add_inline_css() { + // Hide menu item on all pages. + echo ''; + + // Only add inline CSS on the PP pages. + $current_screen = \get_current_screen(); + if ( ! $current_screen || + ( 'toplevel_page_progress-planner' !== $current_screen->id && 'progress-planner_page_progress-planner-settings' !== $current_screen->id ) + ) { + return; + } + + $colors = \get_option( self::OPTION_NAME, [] ); + if ( empty( $colors ) ) { + return; + } + + $css = ':root {'; + foreach ( $colors as $variable => $value ) { + $css .= "\n\t--prpl-{$variable}: {$value};"; + } + $css .= "\n}"; + + echo ''; + } + + /** + * Render the admin page. + * + * @return void + */ + public function render_page() { + $colors = \get_option( self::OPTION_NAME, [] ); + $color_variables = $this->get_color_variables(); + + ?> +
+

Progress Planner Color Customizer

+

Customize the colors used throughout the Progress Planner interface. Changes will be applied after you save.

+ +
+ + + $variables ) : ?> + +
+

+
+ $default_value ) : ?> + +
+ + + + +
+ +
+
+ + +
+ + + 'this.form.action.value = "reset_colors"; return confirm("Are you sure you want to reset all colors to defaults?");' ] ); ?> +
+
+ +
+

Import / Export

+
+
+ + + +
+ +
+ + + + +
+
+
+
+ [ + 'background' => '#f6f7f9', + 'background-banner' => '#f9b23c', + ], + 'Paper' => [ + 'background-paper' => '#fff', + 'color-border' => '#d1d5db', + 'color-divider' => '#d1d5db', + 'color-shadow-paper' => '#000', + ], + 'Graph' => [ + 'color-gauge-main' => '#e1e3e7', + 'graph-color-1' => '#f43f5e', + 'graph-color-2' => '#faa310', + 'graph-color-3' => '#14b8a6', + 'graph-color-4' => '#534786', + ], + 'Table' => [ + 'background-table' => '#f6f7f9', + 'background-top-task' => '#fff9f0', + 'color-border-top-task' => '#faa310', + 'color-border-next-top-task' => '#534786', + 'color-selection-controls-inactive' => '#9ca3af', + 'color-selection-controls' => '#9ca3af', + 'color-ui-icon' => '#6b7280', + 'color-ui-icon-hover' => '#1e40af', + 'color-ui-icon-hover-fill' => '#effbfe', + 'color-ui-icon-hover-delete' => '#e73136', + 'background-point' => '#f9b23c', + 'text-point' => '#38296d', + 'background-point-inactive' => '#d1d5db', + 'text-point-inactive' => '#38296d', + ], + 'Text' => [ + 'color-text' => '#4b5563', + 'color-text-hover' => '#1e40af', + 'color-headings' => '#38296d', + 'color-subheadings' => '#38296d', + 'color-link' => '#1e40af', + 'color-link-hover' => '#4b5563', + 'color-link-visited' => '#534786', + ], + 'Topics' => [ + 'color-monthly' => '#faa310', + 'color-streak' => '#faa310', + 'color-content-badge' => '#faa310', + 'background-monthly' => '#fff9f0', + 'background-content' => '#f6f5fb', + 'background-activity' => '#f2faf9', + 'background-streak' => '#fff6f7', + 'background-content-badge' => '#effbfe', + ], + 'Alert Success' => [ + 'color-alert-success' => '#16a34a', + 'color-alert-success-text' => '#14532d', + 'background-alert-success' => '#f0fdf4', + ], + 'Alert Error' => [ + 'color-alert-error' => '#e73136', + 'color-alert-error-text' => '#7f1d1d', + 'background-alert-error' => '#fdeded', + ], + 'Alert Warning' => [ + 'color-alert-warning' => '#eab308', + 'color-alert-warning-text' => '#713f12', + 'background-alert-warning' => '#fefce8', + ], + 'Alert Info' => [ + 'color-alert-info' => '#2563eb', + 'color-alert-info-text' => '#1e3a8a', + 'background-alert-info' => '#eff6ff', + ], + 'Button' => [ + 'color-button-primary' => '#dd324f', + 'color-button-primary-hover' => '#cf2441', + 'color-button-primary-shadow' => '#000', + 'color-button-primary-border' => 'none', + 'color-button-primary-text' => '#fff', + ], + 'Settings Page' => [ + 'color-setting-pages-icon' => '#faa310', + 'color-setting-posts-icon' => '#534786', + 'color-setting-login-icon' => '#14b8a6', + 'background-setting-pages' => '#fff9f0', + 'background-setting-posts' => '#f6f5fb', + 'background-setting-login' => '#f2faf9', + 'color-border-settings' => '#d1d5db', + ], + 'Input Field Dropdown' => [ + 'color-field-border' => '#d1d5db', + 'color-text-placeholder' => '#6b7280', + 'color-text-dropdown' => '#4b5563', + 'color-field-shadow' => '#000', + ], + ]; + } + + /** + * Normalize color value to 6-digit hex format. + * + * @param string $color_value The color value to normalize. + * + * @return string + */ + private function normalize_color_value( $color_value ) { + // Handle null or empty values. + if ( empty( $color_value ) ) { + return '#000000'; + } + + // Handle special cases. + if ( 'none' === $color_value ) { + return '#000000'; + } + + // If it's already a 6-digit hex, return as is. + if ( \preg_match( '/^#[0-9A-Fa-f]{6}$/', $color_value ) ) { + return \strtolower( $color_value ); + } + + // Convert 3-digit hex to 6-digit. + if ( \preg_match( '/^#[0-9A-Fa-f]{3}$/', $color_value ) ) { + $hex = \substr( $color_value, 1 ); + return '#' . \strtolower( $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2] ); + } + + // If it's not a valid hex color, return black as fallback. + return '#000000'; + } + + /** + * Get custom CSS for the color customizer page. + * + * @return string + */ + private function get_customizer_css() { + return ' + .color-section { + margin-bottom: 30px; + padding: 20px; + background: #fff; + border: 1px solid #ddd; + border-radius: 4px; + } + + .color-section h2 { + margin-top: 0; + color: var(--prpl-color-headings); + border-bottom: 2px solid var(--prpl-color-border); + padding-bottom: 10px; + } + + .color-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin-top: 20px; + } + + .color-field { + display: flex; + flex-direction: column; + gap: 8px; + } + + .color-field label { + font-weight: 600; + color: var(--prpl-color-headings); + } + + .default-value { + font-weight: normal; + font-size: 12px; + color: var(--prpl-color-ui-icon); + display: block; + } + + .color-picker { + width: 60px; + height: 40px; + border: 1px solid var(--prpl-color-border); + border-radius: 4px; + cursor: pointer; + } + + .color-text-input { + padding: 8px; + border: 1px solid var(--prpl-color-border); + border-radius: 4px; + font-family: monospace; + font-size: 12px; + } + + .form-actions { + margin: 30px 0; + padding: 20px; + background: #fff; + border: 1px solid #ddd; + border-radius: 4px; + } + + .import-export-section { + margin-top: 30px; + padding: 20px; + background: #fff; + border: 1px solid #ddd; + border-radius: 4px; + } + + .import-export-section h2 { + margin-top: 0; + color: var(--prpl-color-headings); + } + + .import-export-actions { + display: flex; + align-items: center; + gap: 20px; + } + + .import-export-actions input[type="file"] { + margin-right: 10px; + } + '; + } +} diff --git a/classes/utils/class-debug-tools.php b/classes/utils/class-debug-tools.php index 5578f7012..9a416d949 100644 --- a/classes/utils/class-debug-tools.php +++ b/classes/utils/class-debug-tools.php @@ -47,9 +47,15 @@ public function __construct() { \add_action( 'init', [ $this, 'check_delete_badges' ] ); \add_action( 'init', [ $this, 'check_toggle_migrations' ] ); \add_action( 'init', [ $this, 'check_delete_single_task' ] ); + \add_action( 'init', [ $this, 'check_toggle_recommendations_ui' ] ); + if ( \defined( '\IS_PLAYGROUND_PREVIEW' ) && \constant( '\IS_PLAYGROUND_PREVIEW' ) === true ) { + \add_action( 'init', [ $this, 'check_toggle_placeholder_demo' ] ); + } + + // Initialize color customizer. + $this->get_color_customizer(); - // Add filter to modify the maximum number of suggested tasks to display. - \add_filter( 'progress_planner_suggested_tasks_max_items_per_category', [ $this, 'check_show_all_suggested_tasks' ] ); + \add_filter( 'progress_planner_tasks_show_ui', [ $this, 'filter_tasks_show_ui' ] ); } /** @@ -71,18 +77,18 @@ public function add_toolbar_items( $admin_bar ) { ] ); - $this->add_delete_submenu_item( $admin_bar ); - // Show all suggested tasks. $admin_bar->add_node( [ 'id' => 'prpl-show-all-suggested-tasks', 'parent' => 'prpl-debug', 'title' => 'Show All Suggested Tasks', - 'href' => \add_query_arg( 'prpl_show_all_suggested_tasks', '99', $this->current_url ), + 'href' => \add_query_arg( 'prpl_show_all_recommendations', $this->current_url ), ] ); + $this->add_delete_submenu_item( $admin_bar ); + $this->add_upgrading_tasks_submenu_item( $admin_bar ); $this->add_suggested_tasks_submenu_item( $admin_bar ); @@ -92,6 +98,20 @@ public function add_toolbar_items( $admin_bar ) { $this->add_more_info_submenu_item( $admin_bar ); $this->add_toggle_migrations_submenu_item( $admin_bar ); + + $this->add_toggle_recommendations_ui_submenu_item( $admin_bar ); + + // Add color customizer item. + $admin_bar->add_node( + [ + 'id' => 'prpl-color-customizer', + 'parent' => 'prpl-debug', + 'title' => 'Color Customizer', + 'href' => \admin_url( 'admin.php?page=progress-planner-color-customizer' ), + ] + ); + + $this->add_placeholder_demo_submenu_item( $admin_bar ); } /** @@ -245,7 +265,7 @@ protected function add_suggested_tasks_submenu_item( $admin_bar ) { // Add delete button. $delete_url = \add_query_arg( [ - 'prpl_delete_single_task' => $task->task_id, + 'prpl_delete_single_task' => \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ), '_wpnonce' => \wp_create_nonce( 'prpl_debug_tools' ), ], $this->current_url @@ -314,6 +334,27 @@ protected function add_toggle_migrations_submenu_item( $admin_bar ) { ); } + /** + * Add Toggle Recommendations UI submenu to the debug menu. + * + * @param \WP_Admin_Bar $admin_bar The WordPress admin bar object. + * @return void + */ + protected function add_toggle_recommendations_ui_submenu_item( $admin_bar ) { + $debug_enabled = \get_option( 'prpl_debug_recommendations_ui', false ); + $title = $debug_enabled ? 'Recommendations UI Enabled' : 'Recommendations UI Disabled'; + $href = \add_query_arg( 'prpl_toggle_recommendations_ui', '1', $this->current_url ); + + $admin_bar->add_node( + [ + 'id' => 'prpl-toggle-recommendations-ui', + 'parent' => 'prpl-debug', + 'title' => $title, + 'href' => $href, + ] + ); + } + /** * Check and process the toggle migrations action. * @@ -347,6 +388,39 @@ public function check_toggle_migrations() { exit; } + /** + * Check and process the toggle recommendations UI action. + * + * Toggles the debug option if the appropriate query parameter is set + * and user has required capabilities. + * + * @return void + */ + public function check_toggle_recommendations_ui() { + if ( + ! isset( $_GET['prpl_toggle_recommendations_ui'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $_GET['prpl_toggle_recommendations_ui'] !== '1' || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ! \current_user_can( 'manage_options' ) + ) { + return; + } + + // Verify nonce for security. + $this->verify_nonce(); + + // Toggle the debug option. + $current_value = \get_option( 'prpl_debug_recommendations_ui', false ); + if ( $current_value ) { + \delete_option( 'prpl_debug_recommendations_ui' ); + } else { + \update_option( 'prpl_debug_recommendations_ui', true ); + } + + // Redirect to the same page without the parameter. + \wp_safe_redirect( \remove_query_arg( [ 'prpl_toggle_recommendations_ui', '_wpnonce' ] ) ); + exit; + } + /** * Check and process the delete tasks action. * @@ -413,29 +487,6 @@ public function check_delete_badges() { exit; } - /** - * Modify the maximum number of suggested tasks to display. - * - * @param array $max_items_per_category Array of maximum items per category. - * @return array Modified array of maximum items per category. - */ - public function check_show_all_suggested_tasks( $max_items_per_category ) { - if ( - ! isset( $_GET['prpl_show_all_suggested_tasks'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended - ! \current_user_can( 'manage_options' ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended - ) { - return $max_items_per_category; - } - - $max_items = \absint( \wp_unslash( $_GET['prpl_show_all_suggested_tasks'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended - - foreach ( $max_items_per_category as $key => $value ) { - $max_items_per_category[ $key ] = $max_items; - } - - return $max_items_per_category; - } - /** * Add More Info submenu to the debug menu. * @@ -617,4 +668,89 @@ public function check_delete_single_task() { \wp_safe_redirect( \remove_query_arg( [ 'prpl_delete_single_task', '_wpnonce' ] ) ); exit; } + + /** + * Add Placeholder Demo submenu to the debug menu. + * + * @param \WP_Admin_Bar $admin_bar The WordPress admin bar object. + * @return void + */ + protected function add_placeholder_demo_submenu_item( $admin_bar ) { + $demo_enabled = isset( $_COOKIE['prpl_placeholder_demo'] ) && '1' === $_COOKIE['prpl_placeholder_demo']; + $title = $demo_enabled ? 'Placeholder Demo Enabled' : 'Placeholder Demo Disabled'; + $href = \add_query_arg( 'prpl_toggle_placeholder_demo', '1', $this->current_url ); + + $admin_bar->add_node( + [ + 'id' => 'prpl-placeholder-demo', + 'parent' => 'prpl-debug', + 'title' => $title, + 'href' => $href, + ] + ); + } + + /** + * Check and process the toggle placeholder demo action. + * + * Toggles the placeholder demo cookie if the appropriate query parameter is set + * and user has required capabilities. + * + * @return void + */ + public function check_toggle_placeholder_demo() { + if ( + ! isset( $_GET['prpl_toggle_placeholder_demo'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $_GET['prpl_toggle_placeholder_demo'] !== '1' || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ! \current_user_can( 'manage_options' ) + ) { + return; + } + + // Verify nonce for security. + $this->verify_nonce(); + + // Toggle the cookie. + $current_value = isset( $_COOKIE['prpl_placeholder_demo'] ) && '1' === $_COOKIE['prpl_placeholder_demo']; + if ( $current_value ) { + \setcookie( 'prpl_placeholder_demo', '0', \time() - 3600, \COOKIEPATH, \COOKIE_DOMAIN ); // @phpstan-ignore-line constant.notFound + } else { + \setcookie( 'prpl_placeholder_demo', '1', \time() + ( 30 * DAY_IN_SECONDS ), \COOKIEPATH, \COOKIE_DOMAIN ); // @phpstan-ignore-line constant.notFound + } + + // Clear cache since branding data is cached. + if ( \function_exists( 'progress_planner' ) ) { + \progress_planner()->get_utils__cache()->delete_all(); + } + + // Redirect to the same page without the parameter. + \wp_safe_redirect( \remove_query_arg( [ 'prpl_toggle_placeholder_demo', '_wpnonce' ] ) ); + exit; + } + + /** + * Get color customizer instance. + * + * @return \Progress_Planner\Utils\Color_Customizer + */ + public function get_color_customizer() { + static $color_customizer = null; + if ( null === $color_customizer ) { + $color_customizer = new Color_Customizer(); + } + return $color_customizer; + } + + /** + * Filter the tasks show UI. + * + * @param bool $show_ui The show UI. + * @return bool + */ + public function filter_tasks_show_ui( $show_ui ) { + if ( \get_option( 'prpl_debug_recommendations_ui', false ) ) { + return true; + } + return $show_ui; + } } diff --git a/classes/utils/class-deprecations.php b/classes/utils/class-deprecations.php index 23bcf79f7..b07222900 100644 --- a/classes/utils/class-deprecations.php +++ b/classes/utils/class-deprecations.php @@ -30,7 +30,6 @@ class Deprecations { 'Progress_Planner\Widgets\Activity_Scores' => [ 'Progress_Planner\Admin\Widgets\Activity_Scores', '1.1.1' ], 'Progress_Planner\Widgets\Badge_Streak' => [ 'Progress_Planner\Admin\Widgets\Badge_Streak', '1.1.1' ], 'Progress_Planner\Widgets\Challenge' => [ 'Progress_Planner\Admin\Widgets\Challenge', '1.1.1' ], - 'Progress_Planner\Widgets\Latest_Badge' => [ 'Progress_Planner\Admin\Widgets\Latest_Badge', '1.1.1' ], 'Progress_Planner\Widgets\Published_Content' => [ 'Progress_Planner\Admin\Widgets\Published_Content', '1.1.1' ], 'Progress_Planner\Widgets\Todo' => [ 'Progress_Planner\Admin\Widgets\Todo', '1.1.1' ], 'Progress_Planner\Widgets\Whats_New' => [ 'Progress_Planner\Admin\Widgets\Whats_New', '1.1.1' ], @@ -103,7 +102,6 @@ class Deprecations { 'get_widgets__activity_scores' => [ 'get_admin__widgets__activity_scores', '1.1.1' ], 'get_widgets__todo' => [ 'get_admin__widgets__todo', '1.1.1' ], 'get_widgets__challenge' => [ 'get_admin__widgets__challenge', '1.1.1' ], - 'get_widgets__latest_badge' => [ 'get_admin__widgets__latest_badge', '1.1.1' ], 'get_widgets__badge_streak' => [ 'get_admin__widgets__badge_streak', '1.1.1' ], 'get_widgets__published_content' => [ 'get_admin__widgets__published_content', '1.1.1' ], 'get_widgets__whats_new' => [ 'get_admin__widgets__whats_new', '1.1.1' ], diff --git a/classes/utils/class-onboard.php b/classes/utils/class-onboard.php index 24dcdff2f..25ab71890 100644 --- a/classes/utils/class-onboard.php +++ b/classes/utils/class-onboard.php @@ -26,6 +26,9 @@ public function __construct() { // Handle saving data from the onboarding form response. \add_action( 'wp_ajax_progress_planner_save_onboard_data', [ $this, 'save_onboard_response' ] ); + // Detect domain changes. + \add_action( 'shutdown', [ $this, 'detect_site_url_changes' ] ); + if ( \get_option( 'progress_planner_license_key' ) ) { return; } @@ -86,20 +89,142 @@ public function save_onboard_response() { } /** - * Get the remote nonce URL. + * Get the onboarding remote URL. + * + * @param string $endpoint The endpoint to append to the remote URL. * * @return string */ - public function get_remote_nonce_url() { - return \progress_planner()->get_remote_server_root_url() . self::REMOTE_API_URL . 'get-nonce'; + public function get_remote_url( $endpoint = '' ) { + // Sanitize and validate the endpoint parameter. + $endpoint = \sanitize_key( $endpoint ); + $endpoint = \ltrim( $endpoint, '/' ); + + return \progress_planner()->get_remote_server_root_url() . self::REMOTE_API_URL . $endpoint; } /** - * Get the onboarding remote URL. + * Get the remote nonce. * * @return string */ - public function get_remote_url() { - return \progress_planner()->get_remote_server_root_url() . self::REMOTE_API_URL . 'onboard'; + public function get_remote_nonce() { + // Make a POST request to the remote nonce endpoint. + $response = \wp_remote_post( + $this->get_remote_url( 'get-nonce' ), + [ + 'timeout' => 10, + 'body' => [ 'site' => \set_url_scheme( \site_url() ) ], + ] + ); + + if ( \is_wp_error( $response ) ) { + return ''; + } + + $body = \json_decode( \wp_remote_retrieve_body( $response ), true ); + + return isset( $body['nonce'] ) ? $body['nonce'] : ''; + } + + /** + * Make a request to the remote onboarding endpoint. + * + * @param array $data The data to send with the request. + * + * @return string The license key. + */ + public function make_remote_onboarding_request( $data = [] ) { + // Set the data. + if ( ! isset( $data['nonce'] ) ) { + $data['nonce'] = $this->get_remote_nonce(); + } + $data = \wp_parse_args( + $data, + [ + 'site' => \set_url_scheme( \site_url() ), + 'email' => \wp_get_current_user()->user_email, + 'name' => \get_user_meta( \wp_get_current_user()->ID, 'first_name', true ), + 'with-email' => 'yes', + 'timezone_offset' => (float) ( \wp_timezone()->getOffset( new \DateTime( 'midnight' ) ) / 3600 ), + ] + ); + + // Make the request. + $response = \wp_remote_post( + $this->get_remote_url( 'onboard' ), + [ + 'timeout' => 10, + 'body' => $data, + ] + ); + + // Bail early if there is an error. + if ( \is_wp_error( $response ) ) { + return ''; + } + + $body = \json_decode( \wp_remote_retrieve_body( $response ), true ); + + return ! isset( $body['status'] ) + || 'ok' !== $body['status'] + || ! isset( $body['license_key'] ) + ? '' + : $body['license_key']; + } + + /** + * Detect domain changes. + * + * @return void + */ + public function detect_site_url_changes() { + // Only check once per day to avoid performance issues. + if ( \get_transient( 'progress_planner_site_url_check_done' ) ) { + return; + } + \set_transient( 'progress_planner_site_url_check_done', 1, DAY_IN_SECONDS ); + + $saved_site_url = \get_option( 'progress_planner_saved_site_url', false ); + $current_site_url = \set_url_scheme( \site_url() ); + + // Update the saved site URL if it's not set, then bail early. + if ( ! $saved_site_url ) { + \update_option( 'progress_planner_saved_site_url', $current_site_url, false ); + return; + } + + $saved_license_key = \get_option( 'progress_planner_license_key', false ); + + // Bail early if the license key is not set, or if the site URL has not changed. + if ( ! $saved_license_key || $saved_site_url === $current_site_url ) { + return; + } + + // Make a request to the remote endpoint to update the license key. + $response = \wp_remote_post( + $this->get_remote_url( 'change-site-url' ), + [ + 'timeout' => 10, + 'body' => [ + 'license_key' => $saved_license_key, + 'old_url' => $saved_site_url, + 'new_url' => $current_site_url, + 'nonce' => $this->get_remote_nonce(), + ], + ] + ); + + // Bail early if there is an error. + if ( \is_wp_error( $response ) ) { + return; + } + + $body = \json_decode( \wp_remote_retrieve_body( $response ), true ); + + // Update the saved site URL if the request was successful. + if ( isset( $body['status'] ) && 'ok' === $body['status'] ) { + \update_option( 'progress_planner_saved_site_url', $current_site_url, false ); + } } } diff --git a/classes/utils/class-playground.php b/classes/utils/class-playground.php index e722ee260..f201cde65 100644 --- a/classes/utils/class-playground.php +++ b/classes/utils/class-playground.php @@ -18,8 +18,8 @@ class Playground { public function __construct() { \add_action( 'init', [ $this, 'register_hooks' ], 9 ); \add_action( 'plugins_loaded', [ $this, 'enable_debug_tools' ], 1 ); - \add_filter( 'progress_planner_tasks_show_ui', '__return_true' ); \add_action( 'admin_footer', [ $this, 'inject_playground_js_patch' ] ); + \add_action( 'init', [ $this, 'disable_upgrade_tasks_popover' ], 101 ); } /** @@ -54,6 +54,22 @@ public function register_hooks() { \progress_planner()->get_settings()->set( 'activation_date', ( new \DateTime() )->modify( '-2 months' )->format( 'Y-m-d' ) ); } + /** + * Disable the upgrade tasks popover. + * + * @return void + */ + public function disable_upgrade_tasks_popover() { + // This will make the plugin think it was activated, so the upgrade tasks popover will not be shown. + $onboard_task_provider_ids = \apply_filters( 'prpl_onboarding_task_providers', [] ); + + // Update 'progress_planner_previous_version_task_providers' option. + \update_option( 'progress_planner_previous_version_task_providers', \array_unique( $onboard_task_provider_ids ), SORT_REGULAR ); + + // Clear the upgrade popover task provider IDs, this are the 'newly' added task providers, so the upgrade tasks popover will not be shown. + \update_option( 'progress_planner_upgrade_popover_task_provider_ids', [] ); + } + /** * Enable debug tools. * diff --git a/classes/utils/class-plugin-migration-helpers.php b/classes/utils/class-plugin-migration-helpers.php index 2ba5496b1..fec0611a5 100644 --- a/classes/utils/class-plugin-migration-helpers.php +++ b/classes/utils/class-plugin-migration-helpers.php @@ -34,7 +34,6 @@ public static function parse_task_data_from_task_id( $task_id ) { return new Task( [ 'task_id' => $task_id, - 'category' => $task_provider ? $task_provider->get_provider_category() : '', 'provider_id' => $task_provider ? $task_provider->get_provider_id() : '', ] ); @@ -53,7 +52,6 @@ public static function parse_task_data_from_task_id( $task_id ) { return new Task( [ 'task_id' => $task_id, - 'category' => $task_provider ? $task_provider->get_provider_category() : '', 'provider_id' => $task_provider ? $task_provider->get_provider_id() : '', 'date' => \substr( $task_id, $last_pos + 1 ), ] @@ -86,11 +84,6 @@ public static function parse_task_data_from_task_id( $task_id ) { unset( $data['type'] ); } - if ( isset( $data['provider_id'] ) ) { - $task_provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $data['provider_id'] ); // @phpstan-ignore-line - $data['category'] = $task_provider ? $task_provider->get_provider_category() : ''; - } - return new Task( $data ); } } diff --git a/classes/utils/class-system-status.php b/classes/utils/class-system-status.php index 69cc52bb6..a7d719296 100644 --- a/classes/utils/class-system-status.php +++ b/classes/utils/class-system-status.php @@ -72,27 +72,20 @@ public function get_system_status() { $scores = \progress_planner()->get_ui__chart()->get_chart_data( [ - 'items_callback' => function ( $start_date, $end_date ) { - return \progress_planner()->get_activities__query()->query_activities( - [ - 'start_date' => $start_date, - 'end_date' => $end_date, - ] - ); - }, + 'items_callback' => fn( $start_date, $end_date ) => \progress_planner()->get_activities__query()->query_activities( + [ + 'start_date' => $start_date, + 'end_date' => $end_date, + ] + ), 'dates_params' => [ 'start_date' => \DateTime::createFromFormat( 'Y-m-d', \gmdate( 'Y-m-01' ) )->modify( '-6 months' ), 'end_date' => new \DateTime(), 'frequency' => 'monthly', 'format' => 'M', ], - 'count_callback' => function ( $activities, $date ) { - $score = 0; - foreach ( $activities as $activity ) { - $score += $activity->get_points( $date ); - } - return $score * 100 / Base::SCORE_TARGET; - }, + 'count_callback' => fn( $activities, $date ) => + \array_sum( \array_map( fn( $activity ) => $activity->get_points( $date ), $activities ) ) * 100 / Base::SCORE_TARGET, 'normalized' => true, 'max' => 100, ] @@ -115,7 +108,7 @@ public function get_system_status() { $data['recommendations'] = []; foreach ( $ravis_recommendations as $recommendation ) { $r = [ - 'id' => $recommendation->task_id, + 'id' => \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $recommendation->post_name ), 'title' => $recommendation->post_title, 'url' => $recommendation->url, 'provider_id' => $recommendation->get_provider_id(), @@ -140,6 +133,8 @@ public function get_system_status() { ]; } + $data['branding_id'] = (int) \progress_planner()->get_ui__branding()->get_branding_id(); + return $data; } } diff --git a/classes/wp-cli/class-task-command.php b/classes/wp-cli/class-task-command.php index 1da429580..942d775a3 100644 --- a/classes/wp-cli/class-task-command.php +++ b/classes/wp-cli/class-task-command.php @@ -63,7 +63,7 @@ public function list( $args, $assoc_args ) { } $format = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table'; - $fields = isset( $assoc_args['fields'] ) ? \explode( ',', $assoc_args['fields'] ) : [ 'task_id', 'provider_id', 'category', 'date', 'post_status' ]; + $fields = isset( $assoc_args['fields'] ) ? \explode( ',', $assoc_args['fields'] ) : [ 'task_id', 'provider_id', 'date', 'post_status' ]; $formatted_tasks = []; foreach ( $tasks as $task ) { @@ -82,9 +82,6 @@ public function list( $args, $assoc_args ) { case 'provider_id': $formatted[ $field ] = \is_object( $task->provider ?? null ) && isset( $task->provider->name ) ? $task->provider->name : ''; break; - case 'category': - $formatted[ $field ] = \is_object( $task->category ?? null ) && isset( $task->category->name ) ? $task->category->name : ''; - break; default: $formatted[ $field ] = $task->$field ?? ''; } @@ -296,9 +293,6 @@ private function delete_task( $task_id, $force ) { * [--provider_id=] * : The provider ID. Default: "collaborator" * - * [--category=] - * : The task category. Default: "collaborator" - * * [--status=] * : The task status. Default: "pending" * @@ -324,7 +318,6 @@ public function create( $args, $assoc_args ) { $description = isset( $assoc_args['description'] ) ? $assoc_args['description'] : 'Test description '; $points = isset( $assoc_args['points'] ) ? (int) $assoc_args['points'] : 1; $provider_id = isset( $assoc_args['provider_id'] ) ? $assoc_args['provider_id'] : 'collaborator'; - $category = isset( $assoc_args['category'] ) ? $assoc_args['category'] : 'collaborator'; $status = isset( $assoc_args['status'] ) ? $assoc_args['status'] : 'pending'; $is_completed_callback = isset( $assoc_args['is_completed_callback'] ) ? $assoc_args['is_completed_callback'] : null; $dismissable = isset( $assoc_args['dismissable'] ) ? $assoc_args['dismissable'] : true; @@ -344,7 +337,6 @@ public function create( $args, $assoc_args ) { 'description' => $description, 'points' => $points, 'provider_id' => $provider_id, - 'category' => $category, 'status' => $status, 'dismissable' => $dismissable, 'snoozable' => $snoozable, diff --git a/package-lock.json b/package-lock.json index 345025206..da733a5a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17993,10 +17993,11 @@ } }, "node_modules/tar-fs": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", - "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "dev": true, + "license": "MIT", "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" diff --git a/package.json b/package.json index 20ffacbe1..add362295 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,8 @@ "format": "wp-scripts format ./assets", "lint:css": "wp-scripts lint-style \"**/*.css\"", "lint:css:fix": "npm run lint:css -- --fix", - "lint:js": "wp-scripts lint-js ./assets/js/*.js && wp-scripts lint-js ./assets/js/web-components/*.js && wp-scripts lint-js ./assets/js/widgets/*.js && wp-scripts lint-js ./assets/js/recommendations/*.js && wp-scripts lint-js ./tests/e2e/*.js", - "lint:js:fix": "wp-scripts lint-js ./assets/js/*.js --fix && wp-scripts lint-js ./assets/js/web-components/*.js --fix && wp-scripts lint-js ./assets/js/widgets/*.js --fix && wp-scripts lint-js ./assets/js/recommendations/*.js --fix && wp-scripts lint-js ./tests/e2e/*.js --fix", + "lint:js": "wp-scripts lint-js ./assets/js/*.js && wp-scripts lint-js ./assets/js/web-components/*.js && wp-scripts lint-js ./assets/js/widgets/*.js && wp-scripts lint-js ./assets/js/recommendations/*.js && wp-scripts lint-js ./tests/**/*.js", + "lint:js:fix": "wp-scripts lint-js ./assets/js/*.js --fix && wp-scripts lint-js ./assets/js/web-components/*.js --fix && wp-scripts lint-js ./assets/js/widgets/*.js --fix && wp-scripts lint-js ./assets/js/recommendations/*.js --fix && wp-scripts lint-js ./tests/**/*.js --fix", "prepare": "husky", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0f6866622..ca24110c3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -42,3 +42,9 @@ parameters: - classes/suggested-tasks/data-collector/class-terms-without-description.php - classes/suggested-tasks/data-collector/class-post-tag-count.php - classes/suggested-tasks/data-collector/class-post-author.php + - classes/suggested-tasks/providers/integrations/aioseo/class-archive-author.php + - classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php + - classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php + - classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-global-comments.php + - classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php + - classes/suggested-tasks/providers/integrations/aioseo/class-organization-logo.php diff --git a/progress-planner.php b/progress-planner.php index 0acc23b61..b7eb26033 100644 --- a/progress-planner.php +++ b/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.8.1 + * Version: 1.9.0 * Author: Team Emilia Projects * Author URI: https://prpl.fyi/about * License: GPL-3.0+ diff --git a/readme.txt b/readme.txt index 3bd007467..d0fd83b9b 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: planning, maintenance, writing, blogging Requires at least: 6.3 Tested up to: 6.8 Requires PHP: 7.4 -Stable tag: 1.8.1 +Stable tag: 1.9.0 License: GPL3+ License URI: https://www.gnu.org/licenses/gpl-3.0.en.html @@ -83,6 +83,38 @@ https://youtu.be/e1bmxZYyXFY == Changelog == += 1.9.0 = + +In this release we've added an integration with the **All In One Seo** plugin so you’ll now see personalized suggestions based on your current SEO configuration. + +Added these recommendations from Ravi: + +* Install an SEO plugin +* Improve PDF handling if the site has many PDFs +* All In One SEO: [noindex the author archive](https://prpl.fyi/aioseo-author-archive) +* All In One SEO: [noindex the date archive](https://prpl.fyi/aioseo-date-archive) +* All In One SEO: [Remove post authors feeds](https://prpl.fyi/aioseo-crawl-optimization-feed-authors) +* All In One SEO: [Remove comment feeds](https://prpl.fyi/aioseo-crawl-optimization-feed-comments) +* All In One SEO: [disable the media pages](https://prpl.fyi/aioseo-media-pages) +* All In One SEO: set your [organization](https://prpl.fyi/aioseo-organization-logo) or [person](https://prpl.fyi/aioseo-person-logo) logo + +Enhancements: + +* Add "Show all Recommendations" button to the "Ravi's Recommendations" widget +* Performance improvements +* Removed the "Latest Badge" widget from the plugin page +* Styling improvements +* Improved task prioritization +* Converted many tasks to be "Interactive", improving user-experience. +* Dont display tasks to users which dont't have capability to complete them +* Proactively ran a complete security audit of the plugin, and strengthened security + +Bugs we fixed: + +* Fix missing content and streak badges after they are all completed. +* Fix issue when point is sometimes not awarded for completing "Remove term without posts" task. +* "Yoast SEO: set your organization logo" task detects site logo as fallback, same as Yoast SEO does. + = 1.8.1 = - Security fix: Privilege escalation via an AJAX call where authenticated users could update arbitrary site options. diff --git a/tests/e2e/helpers/cleanup.js b/tests/e2e/helpers/cleanup.js index 40c21a528..78402db59 100644 --- a/tests/e2e/helpers/cleanup.js +++ b/tests/e2e/helpers/cleanup.js @@ -12,7 +12,9 @@ const SELECTORS = require( '../constants/selectors' ); */ async function cleanUpPlannerTasks( { page, context, baseUrl } ) { try { - if ( page.isClosed?.() ) return; + if ( page.isClosed?.() ) { + return; + } await page.goto( `${ baseUrl }/wp-admin/admin.php?page=progress-planner` diff --git a/tests/e2e/sequential/onboarding.spec.js b/tests/e2e/sequential/onboarding.spec.js index bba0d115f..4dc140428 100644 --- a/tests/e2e/sequential/onboarding.spec.js +++ b/tests/e2e/sequential/onboarding.spec.js @@ -49,20 +49,10 @@ function onboardingTests( testContext = test ) { ) .click(); - // Wait for continue button and verify it becomes enabled - const continueButton = page.locator( - '#prpl-onboarding-continue-button' - ); - await continueButton.waitFor( { state: 'visible' } ); - await page.waitForSelector( - '#prpl-onboarding-continue-button:not(.prpl-disabled)' - ); - await continueButton.click(); - // Verify onboarding completion by checking for expected elements await expect( page.locator( '.prpl-widget-wrapper.prpl-suggested-tasks' ) - ).toBeVisible( { timeout: 5000 } ); + ).toBeVisible( { timeout: 15000 } ); await expect( page.locator( '.prpl-widget-wrapper.prpl-suggested-tasks .prpl-suggested-tasks-list' diff --git a/tests/e2e/sequential/task-tagline.spec.js b/tests/e2e/sequential/task-tagline.spec.js index 9504f80e3..10b2005f4 100644 --- a/tests/e2e/sequential/task-tagline.spec.js +++ b/tests/e2e/sequential/task-tagline.spec.js @@ -22,7 +22,7 @@ function taglineTests( testContext = test ) { // Find the blog description task const blogDescriptionTask = initialTasks.find( - ( task ) => task.task_id === 'core-blogdescription' + ( task ) => task.post_name === 'core-blogdescription' ); expect( blogDescriptionTask ).toBeDefined(); expect( blogDescriptionTask.post_status ).toBe( 'publish' ); @@ -55,8 +55,8 @@ function taglineTests( testContext = test ) { const finalTasks = await finalResponse.json(); // Find the blog description task again - const updatedTask = finalTasks.find( - ( task ) => task.task_id === 'core-blogdescription' + const updatedTask = finalTasks.find( ( task ) => + task.post_name.startsWith( 'core-blogdescription' ) ); expect( updatedTask ).toBeDefined(); expect( updatedTask.post_status ).toBe( 'pending' ); @@ -100,8 +100,8 @@ function taglineTests( testContext = test ) { const completedTasks = await completedResponse.json(); // Find the blog description task one last time - const completedTask = completedTasks.find( - ( task ) => task.task_id === 'core-blogdescription' + const completedTask = completedTasks.find( ( task ) => + task.post_name.startsWith( 'core-blogdescription' ) ); expect( completedTask ).toBeDefined(); expect( completedTask.post_status ).toBe( 'trash' ); diff --git a/tests/e2e/task-dismissible.spec.js b/tests/e2e/task-dismissible.spec.js index 0c9f38a0f..b1d1a6536 100644 --- a/tests/e2e/task-dismissible.spec.js +++ b/tests/e2e/task-dismissible.spec.js @@ -53,7 +53,7 @@ test.describe( 'PRPL Dismissable Tasks', () => { // Find the completed task const completedTask = completedTasks.find( - ( task ) => task.task_id === taskId + ( task ) => task.post_name === taskId ); expect( completedTask ).toBeDefined(); expect( completedTask.post_status ).toBe( 'trash' ); diff --git a/tests/e2e/task-snooze.spec.js b/tests/e2e/task-snooze.spec.js index df787abf0..a386ce846 100644 --- a/tests/e2e/task-snooze.spec.js +++ b/tests/e2e/task-snooze.spec.js @@ -5,7 +5,7 @@ test.describe( 'PRPL Task Snooze', () => { test( 'Snooze a task for one week', async ( { page, request } ) => { // Navigate to Progress Planner dashboard with show all tasks parameter await page.goto( - `${ process.env.WORDPRESS_URL }/wp-admin/admin.php?page=progress-planner&prpl_show_all_suggested_tasks=99` + `${ process.env.WORDPRESS_URL }/wp-admin/admin.php?page=progress-planner&prpl_show_all_recommendations` ); await page.waitForLoadState( 'networkidle' ); @@ -22,13 +22,13 @@ test.describe( 'PRPL Task Snooze', () => { // Find a task that's not completed or snoozed const taskToSnooze = initialTasks.find( - ( task ) => task.task_id === snoozeTaskId + ( task ) => task.post_name === snoozeTaskId ); if ( taskToSnooze ) { // Hover over the task to show actions const taskElement = page.locator( - `li[data-task-id="${ taskToSnooze.task_id }"]` + `li[data-task-id="${ taskToSnooze.post_name }"]` ); await taskElement.hover(); @@ -47,7 +47,7 @@ test.describe( 'PRPL Task Snooze', () => { // Select 1 week duration by clicking the label await page.evaluate( ( taskToBeSnoozed ) => { const radio = document.querySelector( - `li[data-task-id="${ taskToBeSnoozed.task_id }"] .prpl-snooze-duration-radio-group input[type="radio"][value="1-week"]` + `li[data-task-id="${ taskToBeSnoozed.post_name }"] .prpl-snooze-duration-radio-group input[type="radio"][value="1-week"]` ); const label = radio.closest( 'label' ); label.click(); @@ -67,7 +67,7 @@ test.describe( 'PRPL Task Snooze', () => { ); const updatedTasks = await updatedResponse.json(); const updatedTask = updatedTasks.find( - ( task ) => task.task_id === taskToSnooze.task_id + ( task ) => task.post_name === taskToSnooze.post_name ); expect( updatedTask.post_status ).toBe( 'future' ); } diff --git a/tests/e2e/yoast-focus-element.spec.js b/tests/e2e/yoast-focus-element.spec.js index 8140c9515..481f238ac 100644 --- a/tests/e2e/yoast-focus-element.spec.js +++ b/tests/e2e/yoast-focus-element.spec.js @@ -11,6 +11,12 @@ test.describe( 'Yoast Focus Element', () => { '/wp-admin/admin.php?page=wpseo_page_settings#/crawl-optimization' ); + // If there is an modal with overlay (which prevents clicks), close it. + const closeButton = page.locator( 'button.yst-modal__close-button' ); + if ( await closeButton.isVisible() ) { + await closeButton.click(); + } + // Wait for the page to load and the toggle to be visible await page.waitForSelector( 'button[data-id="input-wpseo-remove_feed_global_comments"]' diff --git a/tests/phpunit/test-class-content.php b/tests/phpunit/test-class-content.php index 987d6db4b..10c4760a6 100644 --- a/tests/phpunit/test-class-content.php +++ b/tests/phpunit/test-class-content.php @@ -216,12 +216,7 @@ public function test_multiple_status_transitions() { ); // Get the types in order. - $types = \array_map( - function ( $activity ) { - return $activity->type; - }, - $activities - ); + $types = \array_map( fn( $activity ) => $activity->type, $activities ); $this->assertCount( 1, $types ); $this->assertContains( 'publish', $types ); // Should have publish when first published. diff --git a/tests/phpunit/test-class-fewer-tags.php b/tests/phpunit/test-class-fewer-tags.php index 4ed190446..0a68f13e9 100644 --- a/tests/phpunit/test-class-fewer-tags.php +++ b/tests/phpunit/test-class-fewer-tags.php @@ -73,6 +73,7 @@ public function test_should_add_task_when_plugin_inactive_and_tags_outnumber_pos // Create a new Fewer_Tags instance here so it's internal cache is populated with the correct data. $this->task_provider = new Fewer_Tags(); + $this->task_provider->init(); $this->assertTrue( $this->task_provider->should_add_task() ); @@ -110,6 +111,7 @@ public function test_should_add_task_when_plugin_inactive_but_tags_dont_outnumbe // Create a new Fewer_Tags instance here so it's internal cache is populated with the correct data. $this->task_provider = new Fewer_Tags(); + $this->task_provider->init(); $this->assertFalse( $this->task_provider->should_add_task() ); diff --git a/tests/phpunit/test-class-page-types.php b/tests/phpunit/test-class-page-types.php index 71ee7c3ec..3e92a6ebb 100644 --- a/tests/phpunit/test-class-page-types.php +++ b/tests/phpunit/test-class-page-types.php @@ -128,15 +128,7 @@ public function test_get_page_types() { $this->assertCount( \count( $lessons ), $page_types ); foreach ( $lessons as $lesson ) { - $this->assertCount( - 1, - \array_filter( - $page_types, - function ( $page_type ) use ( $lesson ) { - return $page_type['slug'] === $lesson['settings']['id']; - } - ) - ); + $this->assertCount( 1, \array_filter( $page_types, fn( $page_type ) => $page_type['slug'] === $lesson['settings']['id'] ) ); } } diff --git a/tests/phpunit/test-class-prpl-recommendations-status-transition.php b/tests/phpunit/test-class-prpl-recommendations-status-transition.php index 4a0a2da31..2ad8eabf7 100644 --- a/tests/phpunit/test-class-prpl-recommendations-status-transition.php +++ b/tests/phpunit/test-class-prpl-recommendations-status-transition.php @@ -60,8 +60,7 @@ function ( $errno, $errstr, $errfile, $errline ) use ( &$errors, &$notices ) { $post_id = \wp_insert_post( $post_data ); - // Add required taxonomy terms for the post. - \wp_set_object_terms( $post_id, 'test-category', 'prpl_recommendations_category' ); + // Add required taxonomy term for the post. \wp_set_object_terms( $post_id, 'test-provider', 'prpl_recommendations_provider' ); // Now publish the post (change status from future to publish). diff --git a/tests/phpunit/test-class-security.php b/tests/phpunit/test-class-security.php new file mode 100644 index 000000000..c9073f9d7 --- /dev/null +++ b/tests/phpunit/test-class-security.php @@ -0,0 +1,1043 @@ +admin_user_id = $this->factory->user->create( + [ + 'role' => 'administrator', + ] + ); + + // Create editor user. + $this->editor_user_id = $this->factory->user->create( + [ + 'role' => 'editor', + ] + ); + } + + /** + * Test that admin referer is checked for AJAX settings form. + * + * This tests the CURRENT behavior where check_admin_referer is used + * instead of check_ajax_referer. + * + * @return void + */ + public function test_settings_form_nonce_check_current_behavior() { + \wp_set_current_user( $this->admin_user_id ); + + // Set up POST data without nonce. + $_POST['pages'] = [ + 'about' => [ + 'have_page' => 'no', + 'id' => 0, + ], + ]; + + // Create the settings page instance. + $settings_page = new Page_Settings(); + + // Expect this to fail due to missing nonce. + $this->expectException( \WPDieException::class ); + $settings_page->store_settings_form_options(); + } + + /** + * Test that only users with manage_options can save settings. + * + * @return void + */ + public function test_settings_form_requires_manage_options() { + \wp_set_current_user( $this->editor_user_id ); + + $_POST['pages'] = [ + 'about' => [ + 'have_page' => 'no', + 'id' => 0, + ], + ]; + + // Create the settings page instance. + $settings_page = new Page_Settings(); + + // Capture the JSON output. + \ob_start(); + $settings_page->store_settings_form_options(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'permission', $result['data']['message'] ); + } + + /** + * Test that settings form properly sanitizes input. + * + * @return void + */ + public function test_settings_form_sanitizes_input() { + \wp_set_current_user( $this->admin_user_id ); + + // Create a page to test with. + $page_id = $this->factory->post->create( + [ + 'post_type' => 'page', + 'post_title' => 'Test Page', + ] + ); + + $_POST['_wpnonce'] = \wp_create_nonce( 'progress_planner' ); + $_POST['pages'] = [ + 'about' => [ + 'have_page' => 'yes', + 'id' => $page_id, + ], + ]; + + // Create the settings page instance. + $settings_page = new Page_Settings(); + + // This should succeed. + \ob_start(); + $settings_page->store_settings_form_options(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + $this->assertTrue( $result['success'] ); + } + + /** + * Test arbitrary options update vulnerability in Tasks_Interactive. + * + * This tests the CURRENT vulnerable behavior where any option can be updated. + * + * @return void + */ + public function test_interactive_task_arbitrary_options_vulnerability() { + \wp_set_current_user( $this->admin_user_id ); + + // Create a mock interactive task provider. + $task = new class() extends Tasks_Interactive { + /** + * Get the provider ID. + * + * @return string + */ + public function get_provider_id() { + return 'test-task'; + } + + /** + * Get the task details. + * + * @param array $task_data The task data. + * @return array + */ + public function get_task_details( $task_data = [] ) { + return []; + } + + /** + * Print the popover form contents. + * + * @return void + */ + public function print_popover_form_contents() {} + + /** + * Evaluate the task. + * + * @return bool + */ + public function evaluate() { + return false; + } + }; + + // Set initial value. + \update_option( 'test_safe_option', 'original_value' ); + \update_option( 'test_dangerous_option', 'original_value' ); + + // Test 1: Try to update an arbitrary option (vulnerable behavior). + $_POST['nonce'] = \wp_create_nonce( 'progress_planner' ); + $_POST['setting'] = 'test_dangerous_option'; // This should be in whitelist. + $_POST['value'] = 'malicious_value'; + $_POST['setting_path'] = '[]'; + + \ob_start(); + $task->handle_interactive_task_submit(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + + // CURRENT VULNERABLE BEHAVIOR: This succeeds. + $this->assertTrue( $result['success'] ); + $this->assertEquals( 'malicious_value', \get_option( 'test_dangerous_option' ) ); + + // Test 2: Try to update a critical WordPress option. + $original_blogname = \get_option( 'blogname' ); + $_POST['setting'] = 'blogname'; + $_POST['value'] = 'Hacked Site'; + + \ob_start(); + $task->handle_interactive_task_submit(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + + // CURRENT VULNERABLE BEHAVIOR: This also succeeds (CRITICAL VULNERABILITY). + $this->assertTrue( $result['success'] ); + $this->assertEquals( 'Hacked Site', \get_option( 'blogname' ) ); + + // Restore original value. + \update_option( 'blogname', $original_blogname ); + } + + /** + * Test that interactive task requires proper nonce. + * + * @return void + */ + public function test_interactive_task_requires_nonce() { + \wp_set_current_user( $this->admin_user_id ); + + $task = new class() extends Tasks_Interactive { + /** + * Get the provider ID. + * + * @return string + */ + public function get_provider_id() { + return 'test-task'; + } + + /** + * Get the task details. + * + * @param array $task_data The task data. + * @return array + */ + public function get_task_details( $task_data = [] ) { + return []; + } + + /** + * Print the popover form contents. + * + * @return void + */ + public function print_popover_form_contents() {} + + /** + * Evaluate the task. + * + * @return bool + */ + public function evaluate() { + return false; + } + }; + + // Test without nonce. + $_POST['nonce'] = 'invalid_nonce'; + $_POST['setting'] = 'test_option'; + $_POST['value'] = 'new_value'; + $_POST['setting_path'] = '[]'; + + \ob_start(); + $task->handle_interactive_task_submit(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'Invalid nonce', $result['data']['message'] ); + } + + /** + * Test that interactive task requires manage_options capability. + * + * @return void + */ + public function test_interactive_task_requires_manage_options() { + \wp_set_current_user( $this->editor_user_id ); + + $task = new class() extends Tasks_Interactive { + /** + * Get the provider ID. + * + * @return string + */ + public function get_provider_id() { + return 'test-task'; + } + + /** + * Get the task details. + * + * @param array $task_data The task data. + * @return array + */ + public function get_task_details( $task_data = [] ) { + return []; + } + + /** + * Print the popover form contents. + * + * @return void + */ + public function print_popover_form_contents() {} + + /** + * Evaluate the task. + * + * @return bool + */ + public function evaluate() { + return false; + } + }; + + $_POST['nonce'] = \wp_create_nonce( 'progress_planner' ); + $_POST['setting'] = 'test_option'; + $_POST['value'] = 'new_value'; + $_POST['setting_path'] = '[]'; + + \ob_start(); + $task->handle_interactive_task_submit(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'permission', $result['data']['message'] ); + } + + /** + * Test nested setting path update. + * + * @return void + */ + public function test_interactive_task_nested_setting_path() { + \wp_set_current_user( $this->admin_user_id ); + + $task = new class() extends Tasks_Interactive { + /** + * Get the provider ID. + * + * @return string + */ + public function get_provider_id() { + return 'test-task'; + } + + /** + * Get the task details. + * + * @param array $task_data The task data. + * @return array + */ + public function get_task_details( $task_data = [] ) { + return []; + } + + /** + * Print the popover form contents. + * + * @return void + */ + public function print_popover_form_contents() {} + + /** + * Evaluate the task. + * + * @return bool + */ + public function evaluate() { + return false; + } + }; + + // Set up a nested option. + \update_option( + 'test_nested_option', + [ + 'level1' => [ + 'level2' => [ + 'level3' => 'original_value', + ], + ], + ] + ); + + $_POST['nonce'] = \wp_create_nonce( 'progress_planner' ); + $_POST['setting'] = 'test_nested_option'; + $_POST['value'] = 'new_value'; + $_POST['setting_path'] = \wp_json_encode( [ 'level1', 'level2', 'level3' ] ); + + \ob_start(); + $task->handle_interactive_task_submit(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + + $this->assertTrue( $result['success'] ); + + $option = \get_option( 'test_nested_option' ); + $this->assertEquals( 'new_value', $option['level1']['level2']['level3'] ); + } + + /** + * Test that the security fix prevents arbitrary options update. + * + * This tests the FIXED behavior with the whitelist in place. + * + * @return void + */ + public function test_interactive_task_whitelist_prevents_arbitrary_updates() { + \wp_set_current_user( $this->admin_user_id ); + + $task = new class() extends Tasks_Interactive { + /** + * Get the provider ID. + * + * @return string + */ + public function get_provider_id() { + return 'test-task'; + } + + /** + * Get the task details. + * + * @param array $task_data The task data. + * @return array + */ + public function get_task_details( $task_data = [] ) { + return []; + } + + /** + * Print the popover form contents. + * + * @return void + */ + public function print_popover_form_contents() {} + + /** + * Evaluate the task. + * + * @return bool + */ + public function evaluate() { + return false; + } + }; + + // Test 1: Try to update a non-whitelisted option (should FAIL with fix). + $original_admin_email = \get_option( 'admin_email' ); + $_POST['nonce'] = \wp_create_nonce( 'progress_planner' ); + $_POST['setting'] = 'admin_email'; // Not in whitelist. + $_POST['value'] = 'hacker@evil.com'; + $_POST['setting_path'] = '[]'; + + \ob_start(); + $task->handle_interactive_task_submit(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + + // FIXED BEHAVIOR: This should fail. + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'Invalid setting', $result['data']['message'] ); + $this->assertEquals( $original_admin_email, \get_option( 'admin_email' ) ); + + // Test 2: Try to update another critical option (should FAIL with fix). + $_POST['setting'] = 'active_plugins'; // Not in whitelist. + $_POST['value'] = 'malicious-plugin/malicious.php'; + + \ob_start(); + $task->handle_interactive_task_submit(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + + // FIXED BEHAVIOR: This should also fail. + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'Invalid setting', $result['data']['message'] ); + } + + /** + * Test that whitelisted options CAN be updated. + * + * @return void + */ + public function test_interactive_task_allows_whitelisted_options() { + \wp_set_current_user( $this->admin_user_id ); + + $task = new class() extends Tasks_Interactive { + /** + * Get the provider ID. + * + * @return string + */ + public function get_provider_id() { + return 'test-task'; + } + + /** + * Get the task details. + * + * @param array $task_data The task data. + * @return array + */ + public function get_task_details( $task_data = [] ) { + return []; + } + + /** + * Print the popover form contents. + * + * @return void + */ + public function print_popover_form_contents() {} + + /** + * Evaluate the task. + * + * @return bool + */ + public function evaluate() { + return false; + } + }; + + // Test updating a whitelisted option (should SUCCEED). + $original_blogdescription = \get_option( 'blogdescription' ); + $_POST['nonce'] = \wp_create_nonce( 'progress_planner' ); + $_POST['setting'] = 'blogdescription'; // This IS in whitelist. + $_POST['value'] = 'New tagline'; + $_POST['setting_path'] = '[]'; + + \ob_start(); + $task->handle_interactive_task_submit(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + + // FIXED BEHAVIOR: This should succeed. + $this->assertTrue( $result['success'] ); + $this->assertEquals( 'New tagline', \get_option( 'blogdescription' ) ); + + // Restore original value. + \update_option( 'blogdescription', $original_blogdescription ); + } + + /** + * Test that the whitelist filter works correctly. + * + * @return void + */ + public function test_interactive_task_whitelist_filter() { + \wp_set_current_user( $this->admin_user_id ); + + // Add a custom option to the whitelist via filter. + $filter_callback = function ( $allowed_options ) { + $allowed_options[] = 'test_custom_allowed_option'; + return $allowed_options; + }; + \add_filter( 'progress_planner_interactive_task_allowed_options', $filter_callback ); + + $task = new class() extends Tasks_Interactive { + /** + * Get the provider ID. + * + * @return string + */ + public function get_provider_id() { + return 'test-task'; + } + + /** + * Get the task details. + * + * @param array $task_data The task data. + * @return array + */ + public function get_task_details( $task_data = [] ) { + return []; + } + + /** + * Print the popover form contents. + * + * @return void + */ + public function print_popover_form_contents() {} + + /** + * Evaluate the task. + * + * @return bool + */ + public function evaluate() { + return false; + } + }; + + // Test updating the custom whitelisted option (should SUCCEED). + \update_option( 'test_custom_allowed_option', 'original' ); + $_POST['nonce'] = \wp_create_nonce( 'progress_planner' ); + $_POST['setting'] = 'test_custom_allowed_option'; + $_POST['value'] = 'custom_value'; + $_POST['setting_path'] = '[]'; + + \ob_start(); + $task->handle_interactive_task_submit(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + + $this->assertTrue( $result['success'] ); + $this->assertEquals( 'custom_value', \get_option( 'test_custom_allowed_option' ) ); + + // Clean up. + \remove_filter( 'progress_planner_interactive_task_allowed_options', $filter_callback ); + \delete_option( 'test_custom_allowed_option' ); + } + + /** + * Test that critical WordPress options are protected. + * + * @return void + */ + public function test_interactive_task_protects_critical_options() { + \wp_set_current_user( $this->admin_user_id ); + + $task = new class() extends Tasks_Interactive { + /** + * Get the provider ID. + * + * @return string + */ + public function get_provider_id() { + return 'test-task'; + } + + /** + * Get the task details. + * + * @param array $task_data The task data. + * @return array + */ + public function get_task_details( $task_data = [] ) { + return []; + } + + /** + * Print the popover form contents. + * + * @return void + */ + public function print_popover_form_contents() {} + + /** + * Evaluate the task. + * + * @return bool + */ + public function evaluate() { + return false; + } + }; + + $critical_options = [ + 'admin_email' => 'admin@example.com', + 'siteurl' => 'https://example.com', + 'home' => 'https://example.com', + 'users_can_register' => '0', + 'active_plugins' => [], + 'default_role' => 'subscriber', + 'wp_user_roles' => [], + ]; + + foreach ( $critical_options as $option => $malicious_value ) { + $original_value = \get_option( $option ); + + $_POST['nonce'] = \wp_create_nonce( 'progress_planner' ); + $_POST['setting'] = $option; + $_POST['value'] = $malicious_value; + $_POST['setting_path'] = '[]'; + + \ob_start(); + $task->handle_interactive_task_submit(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + + // All critical options should be blocked. + $this->assertFalse( $result['success'], "Critical option '$option' should be blocked" ); + $this->assertEquals( $original_value, \get_option( $option ), "Critical option '$option' should not change" ); + } + } + + /** + * Test that AJAX nonce check fix works correctly. + * + * @return void + */ + public function test_settings_form_ajax_nonce_check() { + \wp_set_current_user( $this->admin_user_id ); + + $settings_page = new Page_Settings(); + + // Test with valid AJAX nonce (should succeed). + $_POST['nonce'] = \wp_create_nonce( 'progress_planner' ); + $_POST['pages'] = [ + 'about' => [ + 'have_page' => 'no', + 'id' => 0, + ], + ]; + + \ob_start(); + $settings_page->store_settings_form_options(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + $this->assertTrue( $result['success'] ); + + // Test with invalid nonce (should fail). + $_POST['nonce'] = 'invalid_nonce'; + + \ob_start(); + $settings_page->store_settings_form_options(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'nonce', \strtolower( $result['data']['message'] ) ); + } + + /** + * Test task completion requires valid token (CSRF protection). + * + * @return void + */ + public function test_task_completion_requires_token() { + \wp_set_current_user( $this->admin_user_id ); + + // Create a test task. + $task_id = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'post_title' => 'Test Task', + 'task_id' => 'test-task-csrf', + 'provider_id' => 'test', + 'category' => 'test', + ] + ); + + // Attempt to complete task without token (should FAIL with fix). + $_GET['prpl_complete_task'] = 'test-task-csrf'; + unset( $_GET['token'] ); + + // Mock the dashboard page check. + \add_filter( 'progress_planner_is_dashboard_page', '__return_true' ); + + \progress_planner()->get_suggested_tasks()->maybe_complete_task(); + + // Task should NOT be completed. + $task = \progress_planner()->get_suggested_tasks_db()->get_post( 'test-task-csrf' ); + $this->assertNotEquals( 'pending', $task->post_status ); + + \remove_filter( 'progress_planner_is_dashboard_page', '__return_true' ); + } + + /** + * Test task completion with valid token succeeds. + * + * @return void + */ + public function test_task_completion_with_valid_token() { + \wp_set_current_user( $this->admin_user_id ); + + // Create a test task. + $task_id = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'post_title' => 'Test Task Valid Token', + 'task_id' => 'test-task-valid-token', + 'provider_id' => 'test', + 'category' => 'test', + ] + ); + + // Generate valid token. + $token = \progress_planner()->get_suggested_tasks()->generate_task_completion_token( + 'test-task-valid-token', + $this->admin_user_id + ); + + // Attempt to complete task with valid token (should SUCCEED). + $_GET['prpl_complete_task'] = 'test-task-valid-token'; + $_GET['token'] = $token; + + // Mock the dashboard page check. + \add_filter( 'progress_planner_is_dashboard_page', '__return_true' ); + + \progress_planner()->get_suggested_tasks()->maybe_complete_task(); + + // Task SHOULD be completed. + $task = \progress_planner()->get_suggested_tasks_db()->get_post( 'test-task-valid-token' ); + $this->assertEquals( 'pending', $task->post_status ); + + \remove_filter( 'progress_planner_is_dashboard_page', '__return_true' ); + } + + /** + * Test task completion fails with invalid token. + * + * @return void + */ + public function test_task_completion_with_invalid_token() { + \wp_set_current_user( $this->admin_user_id ); + + // Create a test task. + $task_id = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'post_title' => 'Test Task Invalid Token', + 'task_id' => 'test-task-invalid-token', + 'provider_id' => 'test', + 'category' => 'test', + ] + ); + + // Use invalid token. + $_GET['prpl_complete_task'] = 'test-task-invalid-token'; + $_GET['token'] = 'invalid-token-12345'; + + // Mock the dashboard page check. + \add_filter( 'progress_planner_is_dashboard_page', '__return_true' ); + + \progress_planner()->get_suggested_tasks()->maybe_complete_task(); + + // Task should NOT be completed. + $task = \progress_planner()->get_suggested_tasks_db()->get_post( 'test-task-invalid-token' ); + $this->assertNotEquals( 'pending', $task->post_status ); + + \remove_filter( 'progress_planner_is_dashboard_page', '__return_true' ); + } + + /** + * Test tokens are one-time use. + * + * @return void + */ + public function test_task_completion_token_one_time_use() { + \wp_set_current_user( $this->admin_user_id ); + + // Create two test tasks. + \progress_planner()->get_suggested_tasks_db()->add( + [ + 'post_title' => 'Test Task One Time 1', + 'task_id' => 'test-task-one-time-1', + 'provider_id' => 'test', + 'category' => 'test', + ] + ); + + \progress_planner()->get_suggested_tasks_db()->add( + [ + 'post_title' => 'Test Task One Time 2', + 'task_id' => 'test-task-one-time-2', + 'provider_id' => 'test', + 'category' => 'test', + ] + ); + + // Generate token and complete first task. + $token = \progress_planner()->get_suggested_tasks()->generate_task_completion_token( + 'test-task-one-time-1', + $this->admin_user_id + ); + + $_GET['prpl_complete_task'] = 'test-task-one-time-1'; + $_GET['token'] = $token; + + \add_filter( 'progress_planner_is_dashboard_page', '__return_true' ); + \progress_planner()->get_suggested_tasks()->maybe_complete_task(); + + // First task should be completed. + $task1 = \progress_planner()->get_suggested_tasks_db()->get_post( 'test-task-one-time-1' ); + $this->assertEquals( 'pending', $task1->post_status ); + + // Try to use same token for second task (should FAIL). + $_GET['prpl_complete_task'] = 'test-task-one-time-2'; + // Token is same - already used and deleted. + + \progress_planner()->get_suggested_tasks()->maybe_complete_task(); + + // Second task should NOT be completed. + $task2 = \progress_planner()->get_suggested_tasks_db()->get_post( 'test-task-one-time-2' ); + $this->assertNotEquals( 'pending', $task2->post_status ); + + \remove_filter( 'progress_planner_is_dashboard_page', '__return_true' ); + } + + /** + * Test email AJAX handler uses correct nonce function. + * + * @return void + */ + public function test_email_ajax_uses_correct_nonce() { + \wp_set_current_user( $this->admin_user_id ); + + // Create the email sending task provider. + $email_task = new \Progress_Planner\Suggested_Tasks\Providers\Email_Sending(); + + // Test with invalid nonce (should FAIL). + $_POST['nonce'] = 'invalid_nonce'; + $_POST['email_address'] = 'test@example.com'; + + \ob_start(); + $email_task->ajax_test_email_sending(); + $output = \ob_get_clean(); + + $result = \json_decode( $output, true ); + + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'nonce', \strtolower( $result['data']['message'] ) ); + } + + /** + * Test token generation creates unique tokens. + * + * @return void + */ + public function test_token_generation_uniqueness() { + \wp_set_current_user( $this->admin_user_id ); + + // Generate two tokens for the same task. + $token1 = \progress_planner()->get_suggested_tasks()->generate_task_completion_token( + 'test-task-unique', + $this->admin_user_id + ); + + $token2 = \progress_planner()->get_suggested_tasks()->generate_task_completion_token( + 'test-task-unique', + $this->admin_user_id + ); + + // Tokens should be different (includes random component). + $this->assertNotEquals( $token1, $token2 ); + } + + /** + * Test token expiration. + * + * @return void + */ + public function test_token_expiration() { + \wp_set_current_user( $this->admin_user_id ); + + // Generate token. + $token = \progress_planner()->get_suggested_tasks()->generate_task_completion_token( + 'test-task-expiry', + $this->admin_user_id + ); + + // Manually delete the transient to simulate expiration. + \delete_transient( 'prpl_complete_test-task-expiry_' . $this->admin_user_id ); + + // Try to use expired token (should FAIL). + $_GET['prpl_complete_task'] = 'test-task-expiry'; + $_GET['token'] = $token; + + \add_filter( 'progress_planner_is_dashboard_page', '__return_true' ); + + // Create the task. + \progress_planner()->get_suggested_tasks_db()->add( + [ + 'post_title' => 'Test Task Expiry', + 'task_id' => 'test-task-expiry', + 'provider_id' => 'test', + 'category' => 'test', + ] + ); + + \progress_planner()->get_suggested_tasks()->maybe_complete_task(); + + // Task should NOT be completed. + $task = \progress_planner()->get_suggested_tasks_db()->get_post( 'test-task-expiry' ); + $this->assertNotEquals( 'pending', $task->post_status ); + + \remove_filter( 'progress_planner_is_dashboard_page', '__return_true' ); + } + + /** + * Clean up after tests. + */ + public function tear_down() { + // Clean up test options. + \delete_option( 'test_safe_option' ); + \delete_option( 'test_dangerous_option' ); + \delete_option( 'test_option' ); + \delete_option( 'test_nested_option' ); + + // Clean up test tasks. + $test_tasks = [ + 'test-task-csrf', + 'test-task-valid-token', + 'test-task-invalid-token', + 'test-task-one-time-1', + 'test-task-one-time-2', + 'test-task-expiry', + ]; + + foreach ( $test_tasks as $task_id ) { + $task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_id ); + if ( $task ) { + \wp_delete_post( $task->ID, true ); + } + } + + parent::tear_down(); + } +} diff --git a/tests/phpunit/test-class-terms-without-description-data-collector.php b/tests/phpunit/test-class-terms-without-description-data-collector.php index d997f66ff..7549290e6 100644 --- a/tests/phpunit/test-class-terms-without-description-data-collector.php +++ b/tests/phpunit/test-class-terms-without-description-data-collector.php @@ -134,12 +134,7 @@ public function test_collect_respects_excluded_terms() { $term_id = $term_result['term_id']; // Add filter to exclude the term. - \add_filter( - 'progress_planner_terms_without_description_exclude_term_ids', - function () use ( $term_id ) { - return [ $term_id ]; - } - ); + \add_filter( 'progress_planner_terms_without_description_exclude_term_ids', fn() => [ $term_id ] ); // Get the data. $this->data_collector->update_cache(); diff --git a/tests/phpunit/test-class-terms-without-posts-data-collector.php b/tests/phpunit/test-class-terms-without-posts-data-collector.php index 470530944..94e8b0c72 100644 --- a/tests/phpunit/test-class-terms-without-posts-data-collector.php +++ b/tests/phpunit/test-class-terms-without-posts-data-collector.php @@ -102,12 +102,7 @@ public function test_collect_respects_excluded_terms() { $term_id = $term_result['term_id']; // Add filter to exclude the term. - \add_filter( - 'progress_planner_terms_without_posts_exclude_term_ids', - function () use ( $term_id ) { - return [ $term_id ]; - } - ); + \add_filter( 'progress_planner_terms_without_posts_exclude_term_ids', fn() => [ $term_id ] ); // Get the data. $this->data_collector->update_cache(); diff --git a/tests/phpunit/test-class-upgrade-migration-130.php b/tests/phpunit/test-class-upgrade-migration-130.php index b66cf68cf..2a784fe9d 100644 --- a/tests/phpunit/test-class-upgrade-migration-130.php +++ b/tests/phpunit/test-class-upgrade-migration-130.php @@ -103,13 +103,7 @@ public function test_recreating_tasks_from_activities() { // Verify that every value in the $activity_ids array is present in the $tasks array and has completed status. foreach ( $activity_ids as $activity_id ) { - $matching_tasks = \array_filter( - $tasks, - function ( $task ) use ( $activity_id ) { - return isset( $task['task_id'] ) && - $task['task_id'] === $activity_id; - } - ); + $matching_tasks = \array_filter( $tasks, fn( $task ) => isset( $task['task_id'] ) && $task['task_id'] === $activity_id ); $this->assertNotEmpty( $matching_tasks, diff --git a/tests/phpunit/test-class-upgrade-migration-190.php b/tests/phpunit/test-class-upgrade-migration-190.php new file mode 100644 index 000000000..a226e75d2 --- /dev/null +++ b/tests/phpunit/test-class-upgrade-migration-190.php @@ -0,0 +1,262 @@ +get_suggested_tasks_db()->delete_all_recommendations(); + + // Create tasks with old/incorrect menu_order values. + $tasks_to_create = [ + [ + 'provider_id' => 'update-core', + 'old_priority' => 50, // Was using default priority. + 'expected_priority' => 20, + ], + [ + 'provider_id' => 'review-post', + 'old_priority' => 30, // Was using old HIGH value. + 'expected_priority' => 10, + ], + [ + 'provider_id' => 'wp-debug-display', + 'old_priority' => 50, // Was using default priority. + 'expected_priority' => 10, + ], + [ + 'provider_id' => 'settings-saved', + 'old_priority' => 1, // Old hardcoded value. + 'expected_priority' => 10, + ], + [ + 'provider_id' => 'sending-email', + 'old_priority' => 1, // Old hardcoded value. + 'expected_priority' => 4, + ], + [ + 'provider_id' => 'search-engine-visibility', + 'old_priority' => 50, // Was using default priority. + 'expected_priority' => 5, + ], + [ + 'provider_id' => 'core-permalink-structure', + 'old_priority' => 50, // Was using default priority. + 'expected_priority' => 3, + ], + [ + 'provider_id' => 'remove-terms-without-posts', + 'old_priority' => 60, // Old hardcoded value. + 'expected_priority' => 60, // No change. + ], + ]; + + $created_task_ids = []; + + // Create the tasks with old menu_order values. + foreach ( $tasks_to_create as $task_data ) { + $task_id = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'task_id' => $task_data['provider_id'] . '-test-' . \time(), + 'provider_id' => $task_data['provider_id'], + 'post_title' => 'Test Task for ' . $task_data['provider_id'], + 'post_status' => 'publish', + 'priority' => $task_data['old_priority'], + ] + ); + + $created_task_ids[ $task_data['provider_id'] ] = [ + 'task_id' => $task_id, + 'old_priority' => $task_data['old_priority'], + 'expected_priority' => $task_data['expected_priority'], + ]; + } + + // Verify tasks were created with old priorities. + foreach ( $created_task_ids as $provider_id => $data ) { + $post = \get_post( $data['task_id'] ); + $this->assertNotNull( $post, "Task for provider {$provider_id} should exist" ); + $this->assertEquals( + $data['old_priority'], + (int) $post->menu_order, + "Task for provider {$provider_id} should have old priority before migration" + ); + } + + // Run the migration. + $migration = new \Progress_Planner\Update\Update_190(); + $migration->run(); + + // Call migrate_task_priorities directly since init hook has already run in tests. + $migration->migrate_task_priorities(); + + // Verify tasks have been updated with new priorities. + foreach ( $created_task_ids as $provider_id => $data ) { + $post = \get_post( $data['task_id'] ); + $this->assertNotNull( $post, "Task for provider {$provider_id} should still exist after migration" ); + $this->assertEquals( + $data['expected_priority'], + (int) $post->menu_order, + "Task for provider {$provider_id} should have new priority after migration (expected {$data['expected_priority']}, got {$post->menu_order})" + ); + } + } + + /** + * Test that migration handles tasks with multiple statuses. + * + * @return void + */ + public function test_migrate_task_priorities_multiple_statuses() { + // Delete all existing tasks first. + \progress_planner()->get_suggested_tasks_db()->delete_all_recommendations(); + + $statuses = [ 'publish', 'trash', 'draft', 'future', 'pending' ]; + $created_tasks = []; + + // Create a task for each status. + foreach ( $statuses as $status ) { + $task_id = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'task_id' => 'update-core-' . $status . '-' . \time(), + 'provider_id' => 'update-core', + 'post_title' => 'Test Task with status ' . $status, + 'post_status' => $status, + 'priority' => 50, // Old incorrect priority. + ] + ); + + $created_tasks[ $status ] = $task_id; + } + + // Run the migration. + $migration = new \Progress_Planner\Update\Update_190(); + $migration->run(); + + // Call migrate_task_priorities directly since init hook has already run in tests. + $migration->migrate_task_priorities(); + + // Verify all tasks have been updated regardless of status. + foreach ( $created_tasks as $status => $task_id ) { + $post = \get_post( $task_id ); + $this->assertNotNull( $post, "Task with status {$status} should exist after migration" ); + $this->assertEquals( + 20, + (int) $post->menu_order, + "Task with status {$status} should have updated priority" + ); + } + } + + /** + * Test that migration doesn't break when provider doesn't exist. + * + * @return void + */ + public function test_migrate_task_priorities_missing_provider() { + // Delete all existing tasks first. + \progress_planner()->get_suggested_tasks_db()->delete_all_recommendations(); + + // Create a task with a non-existent provider. + $task_id = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'task_id' => 'non-existent-provider-' . \time(), + 'provider_id' => 'non-existent-provider', + 'post_title' => 'Test Task with non-existent provider', + 'post_status' => 'publish', + 'priority' => 99, + ] + ); + + $post_before = \get_post( $task_id ); + $this->assertEquals( 99, (int) $post_before->menu_order ); + + // Run the migration - should not throw errors. + $migration = new \Progress_Planner\Update\Update_190(); + $migration->run(); + + // Call migrate_task_priorities directly since init hook has already run in tests. + $migration->migrate_task_priorities(); + + // Task should still exist and priority should be unchanged. + $post_after = \get_post( $task_id ); + $this->assertNotNull( $post_after, 'Task with non-existent provider should still exist' ); + $this->assertEquals( + 99, + (int) $post_after->menu_order, + 'Task with non-existent provider should keep original priority' + ); + } + + /** + * Test that migration only updates tasks that need updating. + * + * @return void + */ + public function test_migrate_task_priorities_only_updates_changed() { + // Delete all existing tasks first. + \progress_planner()->get_suggested_tasks_db()->delete_all_recommendations(); + + // Create a task that already has the correct priority. + $task_id_correct = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'task_id' => 'update-core-correct-' . \time(), + 'provider_id' => 'update-core', + 'post_title' => 'Test Task with correct priority', + 'post_status' => 'publish', + 'priority' => 20, + ] + ); + + // Create a task that needs updating. + $task_id_incorrect = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'task_id' => 'update-core-incorrect-' . \time(), + 'provider_id' => 'update-core', + 'post_title' => 'Test Task with incorrect priority', + 'post_status' => 'publish', + 'priority' => 50, // Incorrect, needs updating. + ] + ); + + // Get the post_modified time before migration. + $post_correct_before = \get_post( $task_id_correct ); + $modified_time_before = $post_correct_before->post_modified; + + // Run the migration. + $migration = new \Progress_Planner\Update\Update_190(); + $migration->run(); + + // Call migrate_task_priorities directly since init hook has already run in tests. + $migration->migrate_task_priorities(); + + // Verify both tasks have correct priority after migration. + $post_correct_after = \get_post( $task_id_correct ); + $post_incorrect_after = \get_post( $task_id_incorrect ); + + $this->assertEquals( 20, (int) $post_correct_after->menu_order ); + $this->assertEquals( 20, (int) $post_incorrect_after->menu_order ); + + // The task that already had correct priority should not have been touched. + // (post_modified timestamp should be the same). + $this->assertEquals( + $modified_time_before, + $post_correct_after->post_modified, + 'Task with already correct priority should not be modified' + ); + } +} diff --git a/tests/phpunit/test-class-upgrade-migrations-111.php b/tests/phpunit/test-class-upgrade-migrations-111.php index bf894500a..8b632b66e 100644 --- a/tests/phpunit/test-class-upgrade-migrations-111.php +++ b/tests/phpunit/test-class-upgrade-migrations-111.php @@ -236,14 +236,7 @@ public function test_dataset_1() { // Verify that every value in the $items array is present in the $tasks array and has completed status. foreach ( $migration_map as $item ) { - $matching_tasks = \array_filter( - $tasks, - function ( $task ) use ( $item ) { - return isset( $task['task_id'] ) && - isset( $item['task_id'] ) && - $task['task_id'] === $item['task_id']; - } - ); + $matching_tasks = \array_filter( $tasks, fn( $task ) => isset( $task['task_id'] ) && isset( $item['task_id'] ) && $task['task_id'] === $item['task_id'] ); $this->assertNotEmpty( $matching_tasks, diff --git a/views/admin-page-header.php b/views/admin-page-header.php index 025e5f472..488879a7e 100644 --- a/views/admin-page-header.php +++ b/views/admin-page-header.php @@ -10,22 +10,17 @@ exit; } -// phpcs:ignore WordPress.Security.NonceVerification.Recommended -$progress_planner_active_range = isset( $_GET['range'] ) ? \sanitize_text_field( \wp_unslash( $_GET['range'] ) ) : '-6 months'; -// phpcs:ignore WordPress.Security.NonceVerification.Recommended -$progress_planner_active_frequency = isset( $_GET['frequency'] ) ? \sanitize_text_field( \wp_unslash( $_GET['frequency'] ) ) : 'monthly'; - \do_action( 'progress_planner_admin_page_header_before' ); ?>
- %3$s', \esc_attr( $progress_planner_range ), - \selected( $progress_planner_active_range, $progress_planner_range, false ), + \selected( isset( $_GET['range'] ) ? \sanitize_text_field( \wp_unslash( $_GET['range'] ) ) : '-6 months', $progress_planner_range, false ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended \esc_html( $progress_planner_label ) ); } @@ -73,7 +68,7 @@ \printf( '', \esc_attr( $progress_planner_frequency ), - \selected( $progress_planner_active_frequency, $progress_planner_frequency, false ), + \selected( isset( $_GET['frequency'] ) ? \sanitize_text_field( \wp_unslash( $_GET['frequency'] ) ) : 'monthly', $progress_planner_frequency, false ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended \esc_html( $progress_planner_label ) ); } diff --git a/views/admin-page-settings.php b/views/admin-page-settings.php index 63c75751d..1f7fe5bed 100644 --- a/views/admin-page-settings.php +++ b/views/admin-page-settings.php @@ -14,7 +14,7 @@
diff --git a/views/admin-page.php b/views/admin-page.php index 05c6172fb..26d1fe02a 100644 --- a/views/admin-page.php +++ b/views/admin-page.php @@ -11,18 +11,22 @@ } $prpl_privacy_policy_accepted = \progress_planner()->is_privacy_policy_accepted(); -$prpl_wrapper_class = ''; -if ( ! $prpl_privacy_policy_accepted ) { - $prpl_wrapper_class = 'prpl-pp-not-accepted'; +if ( 0 !== (int) \progress_planner()->get_ui__branding()->get_branding_id() ) { + $prpl_license_key = \progress_planner()->get_utils__onboard()->make_remote_onboarding_request(); + if ( '' !== $prpl_license_key ) { + \update_option( 'progress_planner_license_key', $prpl_license_key ); + $prpl_privacy_policy_accepted = true; + } } ?> -
+
+

the_view( 'admin-page-header.php' ); ?> -
+
get_admin__page()->get_widgets() as $prpl_admin_widget ) : ?> render(); ?> @@ -41,6 +45,16 @@ the_view( 'welcome.php' ); ?>
- + -the_view( 'js-templates/suggested-task.html' ); ?> \ No newline at end of file + + +the_view( 'js-templates/suggested-task.html' ); ?> diff --git a/views/dashboard-widgets/score.php b/views/dashboard-widgets/score.php index c6ecd0e66..5908b4951 100644 --- a/views/dashboard-widgets/score.php +++ b/views/dashboard-widgets/score.php @@ -14,7 +14,7 @@ - - - +
- - - get_admin__widgets__activity_scores()->get_score() ); ?> - + + get_admin__widgets__activity_scores()->get_score() ); ?>
@@ -46,7 +52,15 @@
-

+

+ get_ui__branding()->get_ravi_name() ) + ); + ?> +

    diff --git a/views/js-templates/suggested-task.html b/views/js-templates/suggested-task.html index 7a6ca6751..f4723088c 100644 --- a/views/js-templates/suggested-task.html +++ b/views/js-templates/suggested-task.html @@ -1,9 +1,9 @@