├── CHANGELOG.md ├── README.md ├── assets └── js │ ├── restrict-manage-posts.js │ └── utilities │ └── functions.js ├── bin ├── create-all-branches.sh ├── eslint.sh ├── functions.sh ├── install-wp-tests.sh ├── phpcs.sh ├── phpstan.sh └── psalm.sh ├── composer.json ├── src ├── Api │ ├── Hash.php │ ├── Instantiate.php │ ├── ObjectRegistrarManager.php │ ├── PageTemplates.php │ ├── RegistrarInterface.php │ ├── Shortcode │ │ ├── AbstractShortcode.php │ │ ├── Handler │ │ │ ├── HandlerInterface.php │ │ │ ├── PaginationTrait.php │ │ │ ├── ShortcodeUiTrait.php │ │ │ └── templates │ │ │ │ └── pagination.php │ │ ├── ShortcodeInterface.php │ │ └── ShortcodeRegistrar.php │ ├── TransientsTrait.php │ ├── WpCacheTrait.php │ ├── WpQueryTrait.php │ └── WpRemote.php ├── Exceptions │ └── Exception.php ├── Models │ ├── BaseModel.php │ ├── PageTemplate.php │ ├── WpQuery │ │ ├── QueryArgs.php │ │ └── README.md │ └── WpScript │ │ └── Args.php ├── Plugin │ ├── AbstractContainerProvider.php │ ├── AbstractHookProvider.php │ ├── AbstractPlugin.php │ ├── AbstractSingletonProvider.php │ ├── Container.php │ ├── Container100.php │ ├── Container110.php │ ├── ContainerAwareTrait.php │ ├── HooksTrait.php │ ├── HttpFoundationRequestInterface.php │ ├── HttpFoundationRequestTrait.php │ ├── Init.php │ ├── Plugin.php │ ├── PluginAwareInterface.php │ ├── PluginAwareTrait.php │ ├── PluginFactory.php │ ├── PluginInterface.php │ ├── Provider │ │ └── I18n.php │ ├── README.md │ ├── TemplateLoader.php │ ├── TemplateLoaderInterface.php │ └── WpHooksInterface.php ├── PostTypes │ ├── AbstractPostType.php │ ├── Columns │ │ └── Api │ │ │ ├── ColumnsRegistrar.php │ │ │ └── ColumnsTrait.php │ ├── CustomFields │ │ └── Api │ │ │ ├── CurrentPostTrait.php │ │ │ ├── CustomFieldsRegistrar.php │ │ │ └── CustomFieldsRegistrarInterface.php │ ├── PostTypeTrait.php │ └── PostTypesRegistrar.php ├── ReflectionTrait.php ├── RestApi │ ├── Api │ │ ├── RestRequest.php │ │ └── SunsetsEndpoints.php │ ├── Http │ │ ├── RegisterGetRoute.php │ │ ├── RegisterPostRoute.php │ │ ├── RestRouteInterface.php │ │ └── RouteService.php │ └── PostTypeFilter.php ├── Taxonomies │ ├── AbstractTaxonomy.php │ ├── TaxonomyRegistrar.php │ └── TaxonomyTrait.php ├── Utils │ ├── AbstractSingleton.php │ ├── SingletonInterface.php │ ├── View.php │ └── Viewable.php ├── WpAdmin │ ├── AddPluginIcons.php │ ├── AdminColumns.php │ ├── Capabilities.php │ ├── Columns │ │ └── AbstractColumns.php │ ├── Dashboard │ │ ├── SimplePie.php │ │ └── Widget.php │ ├── DashboardWidget.php │ ├── DisablePluginUpdateCheck.php │ ├── FormElementsTrait.php │ ├── Models │ │ └── OptionValueLabel.php │ ├── RestrictManagePosts.php │ ├── RestrictPostsInterface.php │ ├── Roles │ │ └── RoleManager.php │ └── Users │ │ ├── Fields │ │ ├── FieldType.php │ │ ├── Type.php │ │ └── UserMetaFieldRegistrar.php │ │ ├── Models │ │ └── UserMetaField.php │ │ └── Profile.php └── functions.php └── views ├── dashboard-widget.php ├── dashboard-widget ├── rest.php └── rss.php └── wp-admin └── users └── profile.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented [on GitHub's](https://github.com/thefrosty/wp-utilities/releases) 4 | releases page. 5 | 6 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 7 | and this project adheres to [Semantic Versioning](http://semver.org/). 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frosty's WordPress Utilities 2 | 3 | ![WP Utilities](.github/wp-utilities.jpg?raw=true "Frosty's WordPress Utilities") 4 | 5 | [![PHP from Packagist](https://img.shields.io/packagist/php-v/thefrosty/wp-utilities.svg)]() 6 | [![Latest Stable Version](https://img.shields.io/packagist/v/thefrosty/wp-utilities.svg)](https://packagist.org/packages/thefrosty/wp-utilities) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/thefrosty/wp-utilities.svg)](https://packagist.org/packages/thefrosty/wp-utilities) 8 | [![License](https://img.shields.io/packagist/l/thefrosty/wp-utilities.svg)](https://packagist.org/packages/thefrosty/wp-utilities) 9 | ![Build Status](https://github.com/thefrosty/wp-utilities/actions/workflows/main.yml/badge.svg) 10 | [![codecov](https://codecov.io/gh/thefrosty/wp-utilities/branch/develop/graph/badge.svg?token=UUBVKGTYTG)](https://codecov.io/gh/thefrosty/wp-utilities) 11 | [![Donate with $DOGE](https://img.shields.io/static/v1?style=&logo=dogecoin&label=Donation&message=DFMbUjdxuQNJnbA622e7TNSJ3yxAdAWZEW&color=ba9f33)](#) 12 | 13 | A library containing my standard development resources to build high quality WordPress plugins. 14 | 15 | ### Requirements 16 | 17 | ``` 18 | PHP >= 8.3 19 | WordPress >= 6.7 20 | ``` 21 | 22 | The required WordPress version will always be the most recent point release of 23 | the previous major release branch. 24 | 25 | For both PHP and WordPress requirements, although this library may work with a 26 | version below the required versions, they will not be supported and any 27 | compatibility is entirely coincidental. 28 | 29 | ### Installation 30 | 31 | To install this library, use Composer: 32 | 33 | ``` 34 | composer require thefrosty/wp-utilities:^3.5 35 | ``` 36 | 37 | Then follow examples in the [Plugin README](./src/Plugin/README.md) 38 | -------------------------------------------------------------------------------- /assets/js/restrict-manage-posts.js: -------------------------------------------------------------------------------- 1 | /** global jQuery */ 2 | ;(function ($) { 3 | 'use strict' 4 | 5 | const restrictManagePosts = { 6 | selectMetaKey: {}, 7 | selectMetaValue: {} 8 | } 9 | /** 10 | * Initiate. 11 | */ 12 | restrictManagePosts.init = function () { 13 | restrictManagePosts.selectMetaKey = $('select[name="_filter_meta_key"]') 14 | restrictManagePosts.selectMetaValue = $('select[name="_filter_meta_value"]') 15 | restrictManagePosts.filterClickListener() 16 | restrictManagePosts.filterChangeListener() 17 | if (typeof $.fn.select2 !== 'undefined') { 18 | restrictManagePosts.selectMetaKey.select2() 19 | restrictManagePosts.selectMetaValue.select2() 20 | } 21 | } 22 | /** 23 | * Listen for click events on the filter link to trigger auto population of the filter dropdowns. 24 | */ 25 | restrictManagePosts.filterClickListener = function () { 26 | $('a[data-select2-ajax]').on('click', function (e) { 27 | const $this = $(this) 28 | if ( 29 | $this.data('meta_key') !== 'undefined' && 30 | $this.data('meta_value') !== 'undefined' && 31 | window.WpUtilities.isNumeric($this.data('meta_value')) 32 | ) { 33 | e.preventDefault() 34 | restrictManagePosts.selectMetaKey.val($this.data('meta_key')) 35 | restrictManagePosts.selectMetaValue.val($this.data('meta_value')) 36 | $('#post-query-submit').click() 37 | } 38 | }) 39 | } 40 | /** 41 | * Listen for change events on the Value selector and apply the optgroup label (meta_key) 42 | * as the selected value for this.selectMetaKey. 43 | */ 44 | restrictManagePosts.filterChangeListener = function () { 45 | restrictManagePosts.selectMetaValue.on( 46 | 'change select2:select', 47 | function () { 48 | const $this = $(this.options[this.selectedIndex]) 49 | const optgroup = $this.closest('optgroup') 50 | 51 | if ( 52 | restrictManagePosts.selectMetaKey.val() !== optgroup.prop('label') 53 | ) { 54 | restrictManagePosts.selectMetaKey.val(optgroup.prop('label')) 55 | restrictManagePosts.selectMetaKey.trigger('change.select2') 56 | } 57 | } 58 | ) 59 | } 60 | 61 | $(document).ready(() => restrictManagePosts.init()) 62 | }(jQuery)) 63 | -------------------------------------------------------------------------------- /assets/js/utilities/functions.js: -------------------------------------------------------------------------------- 1 | const WpUtilities = { 2 | /** 3 | * Is the value numeric? 4 | * @link https://stackoverflow.com/a/16655847/558561 5 | * @updated v1.16.0 uses jQuery.isNumeric 6 | * @param val 7 | * @returns {boolean} 8 | */ 9 | isNumeric: (val) => { 10 | const type = typeof val 11 | return (type === 'number' || type === 'string') && 12 | !isNaN(val - parseFloat(val)) 13 | } 14 | } 15 | 16 | window.WpUtilities = WpUtilities 17 | export default WpUtilities 18 | -------------------------------------------------------------------------------- /bin/create-all-branches.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | # https://stackoverflow.com/a/44036486/558561 6 | function create_all_branches() { 7 | # Keep track of where Travis put us. 8 | # We are on a detached head, and we need to be able to go back to it. 9 | local build_head 10 | build_head=$(git rev-parse HEAD) 11 | 12 | # Fetch all the remote branches. Travis clones with `--depth`, which 13 | # implies `--single-branch`, so we need to overwrite remote.origin.fetch to 14 | # do that. 15 | git config --replace-all remote.origin.fetch +refs/heads/*:refs/remotes/origin/* 16 | git fetch --prune 17 | # optionally, we can also fetch the tags, pass `true` to the function call. 18 | if [[ $# -eq 1 ]]; then 19 | git fetch --tags 20 | fi 21 | 22 | # create the tacking branches 23 | for branch in $(git branch -r | grep -v HEAD); do 24 | git checkout -qf "${branch#origin/}" 25 | done 26 | 27 | # finally, go back to where we were at the beginning 28 | git checkout "${build_head}" 29 | } 30 | 31 | if [[ -n "$CI" ]]; then 32 | create_all_branches "$@" 33 | fi; 34 | -------------------------------------------------------------------------------- /bin/eslint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | source "$(dirname "$0")/functions.sh" 6 | echo 'Checking ESLint' 7 | 8 | jsFiles="" 9 | jsFilesCount=0 10 | for f in ${commitFiles}; do 11 | if [[ ! -e ${f} ]]; then 12 | continue 13 | fi 14 | if [[ ${f} =~ \.(js|jsx)$ ]]; then 15 | jsFilesCount=$((jsFilesCount + 1)) 16 | jsFiles="$jsFiles $f" 17 | fi 18 | done 19 | if [[ ${jsFilesCount} == 0 ]]; then 20 | echo "No JS files updated, nothing to check." 21 | exit 0 22 | fi 23 | 24 | jsFiles=$(echo "${jsFiles}" | xargs) 25 | echo "Checking files: $jsFiles" 26 | 27 | # shellcheck disable=SC2086 28 | npx standard ${jsFiles} 29 | -------------------------------------------------------------------------------- /bin/functions.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | # Default values of arguments 6 | # https://stackoverflow.com/a/44750379/558561 -- get the default git branch name 7 | DEFAULT_BRANCH=$(git remote show $(git remote) | sed -n '/HEAD branch/s/.*: //p') 8 | OTHER_ARGUMENTS=() 9 | PHP_VERSION=${PHP_VERSION:-"8.3"} 10 | 11 | # Loop through arguments and process them 12 | # @ref https://pretzelhands.com/posts/command-line-flags/ 13 | function get_arguments() { 14 | for arg in "$@"; do 15 | case $arg in 16 | --default-branch=*) 17 | DEFAULT_BRANCH="${arg#*=}" 18 | shift # Remove --default-branch= from processing 19 | ;; 20 | --test-version=*) 21 | TEST_VERSION="${arg#*=}" 22 | shift # Remove --test-version= from processing 23 | ;; 24 | *) 25 | OTHER_ARGUMENTS+=("$1") 26 | shift # Remove generic argument from processing 27 | ;; 28 | esac 29 | done 30 | } 31 | 32 | # Composer 2.2.x https://getcomposer.org/doc/articles/vendor-binaries.md#finding-the-composer-bin-dir-from-a-binary 33 | if [[ -z "$COMPOSER_BIN_DIR" ]]; then 34 | BIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 35 | else 36 | BIN_DIR="$COMPOSER_BIN_DIR" 37 | fi 38 | 39 | export BIN_DIR 40 | 41 | function get_branch() { 42 | echo "${GITHUB_BASE_REF:=develop}" 43 | } 44 | 45 | # Based off: https://gist.github.com/Hounddog/3891872 46 | # Branch to check current commit against 47 | function get_commit_against() { 48 | if [[ $(git rev-parse --verify HEAD) ]]; then 49 | echo 'HEAD' 50 | elif [[ $(git rev-parse --verify develop) ]]; then 51 | echo 'develop' 52 | elif [[ $(git rev-parse --verify main) ]]; then 53 | echo 'main' 54 | elif [[ $(git rev-parse --verify master) ]]; then 55 | echo 'master' 56 | elif [[ $(git rev-parse --verify "$1") ]]; then 57 | echo "$1" 58 | else 59 | echo "git can't verify HEAD, develop, main, or master." 60 | exit 1 61 | fi 62 | } 63 | 64 | # Helper function to call a bash file with arguments 65 | # $1: The file to call 66 | # $2: The first arguments to pass to the file 67 | # $3: The second arguments to pass to the file 68 | function source_bin_file() { 69 | if [[ ! "${1+x}" ]]; then 70 | echo "Error: missing file" && exit 1 71 | fi 72 | 73 | FILE=./vendor/bin/"$1" 74 | if [[ -f "$FILE" ]]; then 75 | # shellcheck disable=SC2086 76 | "$FILE" "${@:2}" 77 | else 78 | # shellcheck disable=SC2086 79 | "$BIN_DIR"/"$1" "${@:2}" 80 | fi 81 | } 82 | 83 | against=$(get_commit_against "$@") 84 | commit=$(get_branch) 85 | echo "git merge-base commit: ${commit} against: ${against}" 86 | if [[ -z ${CHANGED_FILES+x} ]]; then 87 | #commitFiles=$(git diff --name-only "$(git merge-base "${commit}" "${against}")") 88 | commitFiles=$(git diff --name-only "$(git merge-base ${DEFAULT_BRANCH:-develop} ${against})") 89 | else 90 | commitFiles="${CHANGED_FILES}" 91 | fi 92 | 93 | export commitFiles 94 | -------------------------------------------------------------------------------- /bin/install-wp-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# -lt 3 ]; then 4 | echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" 5 | exit 1 6 | fi 7 | 8 | # Function to check if a command exists 9 | command_exists() { 10 | command -v "$1" >/dev/null 2>&1 11 | } 12 | # Check if SVN is installed 13 | if command_exists svn; then 14 | echo "SVN is already installed." 15 | else 16 | echo "SVN is not installed. Installing SVN..." 17 | # Update the package list 18 | sudo apt-get update -y 19 | # Install SVN 20 | sudo apt-get install -y subversion 21 | # Verify installation 22 | if command_exists svn; then 23 | echo "SVN was successfully installed." 24 | else 25 | echo "Failed to install SVN. Please check your system configuration." 26 | exit 1 27 | fi 28 | fi 29 | 30 | DB_NAME=$1 31 | DB_USER=$2 32 | DB_PASS=$3 33 | DB_HOST=${4-localhost} 34 | WP_VERSION=${5-latest} 35 | SKIP_DB_CREATE=${6-false} 36 | 37 | TMPDIR=${TMPDIR-/tmp} 38 | TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") 39 | WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} 40 | WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress} 41 | 42 | download() { 43 | if [ `which curl` ]; then 44 | curl -s "$1" > "$2"; 45 | elif [ `which wget` ]; then 46 | wget -nv -O "$2" "$1" 47 | fi 48 | } 49 | 50 | if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then 51 | WP_BRANCH=${WP_VERSION%\-*} 52 | WP_TESTS_TAG="branches/$WP_BRANCH" 53 | 54 | elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then 55 | WP_TESTS_TAG="branches/$WP_VERSION" 56 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then 57 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then 58 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x 59 | WP_TESTS_TAG="tags/${WP_VERSION%??}" 60 | else 61 | WP_TESTS_TAG="tags/$WP_VERSION" 62 | fi 63 | elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 64 | WP_TESTS_TAG="trunk" 65 | else 66 | # http serves a single offer, whereas https serves multiple. we only want one 67 | download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json 68 | grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json 69 | LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') 70 | if [[ -z "$LATEST_VERSION" ]]; then 71 | echo "Latest WordPress version could not be found" 72 | exit 1 73 | fi 74 | WP_TESTS_TAG="tags/$LATEST_VERSION" 75 | fi 76 | set -ex 77 | 78 | install_wp() { 79 | 80 | if [ -d $WP_CORE_DIR ]; then 81 | return; 82 | fi 83 | 84 | mkdir -p $WP_CORE_DIR 85 | 86 | if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 87 | mkdir -p $TMPDIR/wordpress-trunk 88 | rm -rf $TMPDIR/wordpress-trunk/* 89 | svn export https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress 90 | mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR 91 | else 92 | if [ $WP_VERSION == 'latest' ]; then 93 | local ARCHIVE_NAME='latest' 94 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then 95 | # https serves multiple offers, whereas http serves single. 96 | download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json 97 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then 98 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x 99 | LATEST_VERSION=${WP_VERSION%??} 100 | else 101 | # otherwise, scan the releases and get the most up to date minor version of the major release 102 | local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'` 103 | LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1) 104 | fi 105 | if [[ -z "$LATEST_VERSION" ]]; then 106 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 107 | else 108 | local ARCHIVE_NAME="wordpress-$LATEST_VERSION" 109 | fi 110 | else 111 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 112 | fi 113 | download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz 114 | tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR 115 | fi 116 | 117 | download https://raw.githubusercontent.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php 118 | } 119 | 120 | install_test_suite() { 121 | # portable in-place argument for both GNU sed and Mac OSX sed 122 | if [[ $(uname -s) == 'Darwin' ]]; then 123 | local ioption='-i.bak' 124 | else 125 | local ioption='-i' 126 | fi 127 | 128 | # set up testing suite if it doesn't yet exist 129 | if [ ! -d $WP_TESTS_DIR ]; then 130 | # set up testing suite 131 | mkdir -p $WP_TESTS_DIR 132 | rm -rf $WP_TESTS_DIR/{includes,data} 133 | svn export --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes 134 | svn export --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data 135 | fi 136 | 137 | if [ ! -f wp-tests-config.php ]; then 138 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php 139 | # remove all forward slashes in the end 140 | WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") 141 | sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 142 | sed $ioption "s:__DIR__ . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 143 | sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php 144 | sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php 145 | sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php 146 | sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php 147 | fi 148 | 149 | } 150 | 151 | recreate_db() { 152 | shopt -s nocasematch 153 | if [[ $1 =~ ^(y|yes)$ ]] 154 | then 155 | mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA 156 | create_db 157 | echo "Recreated the database ($DB_NAME)." 158 | else 159 | echo "Leaving the existing database ($DB_NAME) in place." 160 | fi 161 | shopt -u nocasematch 162 | } 163 | 164 | create_db() { 165 | mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA 166 | } 167 | 168 | install_db() { 169 | 170 | if [ ${SKIP_DB_CREATE} = "true" ]; then 171 | return 0 172 | fi 173 | 174 | # parse DB_HOST for port or socket references 175 | local PARTS=(${DB_HOST//\:/ }) 176 | local DB_HOSTNAME=${PARTS[0]}; 177 | local DB_SOCK_OR_PORT=${PARTS[1]}; 178 | local EXTRA="" 179 | 180 | if ! [ -z $DB_HOSTNAME ] ; then 181 | if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then 182 | EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" 183 | elif ! [ -z $DB_SOCK_OR_PORT ] ; then 184 | EXTRA=" --socket=$DB_SOCK_OR_PORT" 185 | elif ! [ -z $DB_HOSTNAME ] ; then 186 | EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" 187 | fi 188 | fi 189 | 190 | # create database 191 | if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ] 192 | then 193 | echo "Reinstalling will delete the existing test database ($DB_NAME)" 194 | read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB 195 | recreate_db $DELETE_EXISTING_DB 196 | else 197 | create_db 198 | fi 199 | } 200 | 201 | install_wp 202 | install_test_suite 203 | install_db 204 | -------------------------------------------------------------------------------- /bin/phpcs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | source "$(dirname "$0")/functions.sh" 6 | echo 'Checking PHPCS' 7 | 8 | args="${ARGS:=--runtime-set testVersion ${PHP_VERSION}- $*}" 9 | phpFiles="" 10 | phpFilesCount=0 11 | for f in ${commitFiles}; do 12 | if [[ ! -e ${f} ]]; then 13 | continue 14 | fi 15 | if [[ ${f} =~ \.(php|ctp)$ ]]; then 16 | phpFilesCount=$((phpFilesCount + 1)) 17 | phpFiles="$phpFiles $f" 18 | fi 19 | done 20 | if [[ ${phpFilesCount} == 0 ]]; then 21 | echo "No PHP files updated, nothing to check." 22 | exit 0 23 | fi 24 | 25 | phpFiles=$(echo "${phpFiles}" | xargs) 26 | echo "Checking files: $phpFiles" 27 | echo "Args: $args" 28 | 29 | # shellcheck disable=SC2086 30 | source_bin_file phpcs ${args} ${phpFiles} --report-full --report-checkstyle=./phpcs-report.xml 31 | -------------------------------------------------------------------------------- /bin/phpstan.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | source "$(dirname "$0")/functions.sh" 6 | echo 'Running PHPStan' 7 | 8 | against="${GITHUB_HEAD_REF:=$(get_commit_against)}" 9 | commit="${GITHUB_BASE_REF:=develop}" 10 | echo "git merge-base commit: ${commit} against: ${against}" 11 | if [[ -z ${CHANGED_FILES+x} ]]; then 12 | commitFiles=$(git diff --name-only "$(git merge-base "${commit}" "${against}")") 13 | else 14 | commitFiles="${CHANGED_FILES}" 15 | fi 16 | 17 | args="${ARGS:=analyze --memory-limit 1G $*}" 18 | phpFiles="" 19 | phpFilesCount=0 20 | for f in ${commitFiles}; do 21 | if [[ ! -e ${f} ]]; then 22 | continue 23 | fi 24 | if [[ ${f} =~ \.(php|ctp)$ && ! ${f} =~ ^tests/ ]]; then 25 | phpFilesCount=$((phpFilesCount + 1)) 26 | phpFiles="$phpFiles $f" 27 | fi 28 | done 29 | if [[ ${phpFilesCount} == 0 ]]; then 30 | echo "No PHP files updated, nothing to check." 31 | exit 0 32 | fi 33 | 34 | phpFiles=$(echo "${phpFiles}" | xargs) 35 | echo "Checking files: $phpFiles" 36 | 37 | # shellcheck disable=SC2086 38 | source_bin_file phpstan ${args} ${phpFiles} 39 | -------------------------------------------------------------------------------- /bin/psalm.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | source "$(dirname "$0")/functions.sh" 6 | echo 'Running Psalm' 7 | 8 | against="${GITHUB_HEAD_REF:=$(get_commit_against)}" 9 | commit="${GITHUB_BASE_REF:=develop}" 10 | echo "git merge-base commit: ${commit} against: ${against}" 11 | if [[ -z ${CHANGED_FILES+x} ]]; then 12 | commitFiles=$(git diff --name-only "$(git merge-base "${commit}" "${against}")") 13 | else 14 | commitFiles="${CHANGED_FILES}" 15 | fi 16 | 17 | args="${ARGS:=--config=psalm.xml --show-info=true $*}" 18 | phpFiles="" 19 | phpFilesCount=0 20 | for f in ${commitFiles}; do 21 | if [[ ! -e ${f} ]]; then 22 | continue 23 | fi 24 | if [[ ${f} =~ \.(php|ctp)$ && ! ${f} =~ ^tests/ ]]; then 25 | phpFilesCount=$((phpFilesCount + 1)) 26 | phpFiles="$phpFiles $f" 27 | fi 28 | done 29 | if [[ ${phpFilesCount} == 0 ]]; then 30 | echo "No PHP files updated, nothing to check." 31 | exit 0 32 | fi 33 | 34 | phpFiles=$(echo "${phpFiles}" | xargs) 35 | echo "Checking files: $phpFiles" 36 | 37 | # shellcheck disable=SC2086 38 | source_bin_file psalm ${args} ${phpFiles} 39 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thefrosty/wp-utilities", 3 | "description": "A library containing my standard development resources", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Austin Passy", 8 | "email": "thefrosty@users.noreply.github.com", 9 | "homepage": "https://austin.passy.co", 10 | "role": "Developer" 11 | } 12 | ], 13 | "config": { 14 | "allow-plugins": { 15 | "roots/wordpress-core-installer": true, 16 | "dealerdirect/phpcodesniffer-composer-installer": true 17 | }, 18 | "optimize-autoloader": true, 19 | "platform": { 20 | "php": "8.3" 21 | }, 22 | "sort-packages": true 23 | }, 24 | "minimum-stability": "dev", 25 | "prefer-stable": true, 26 | "require": { 27 | "php": ">=8.3", 28 | "ext-json": "*", 29 | "ext-openssl": "*", 30 | "jjgrainger/posttypes": "^2.2", 31 | "johnbillion/args": "^2.1", 32 | "psr/container": "^2.0", 33 | "symfony/http-foundation": "^7.2.1" 34 | }, 35 | "require-dev": { 36 | "ext-simplexml": "*", 37 | "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", 38 | "php-stubs/wordpress-stubs": "^6.8", 39 | "phpcompatibility/php-compatibility": "^9.3", 40 | "phpunit/php-code-coverage": "^11", 41 | "phpunit/phpunit": "^11.1", 42 | "pimple/pimple": "^3.5", 43 | "roave/security-advisories": "dev-latest", 44 | "roots/wordpress": "^6.8", 45 | "slevomat/coding-standard": "^8.18", 46 | "squizlabs/php_codesniffer": "^3.9", 47 | "szepeviktor/phpstan-wordpress": "^2.0", 48 | "wp-coding-standards/wpcs": "^3.1", 49 | "wp-phpunit/wp-phpunit": "^6.8", 50 | "yoast/phpunit-polyfills": "^4.0" 51 | }, 52 | "suggest": { 53 | "jjgrainger/posttypes": "Simple WordPress custom post types.", 54 | "yahnis-elsts/plugin-update-checker": "A custom update checker for WordPress plugins." 55 | }, 56 | "autoload": { 57 | "files": [ 58 | "src/functions.php" 59 | ], 60 | "psr-4": { 61 | "TheFrosty\\WpUtilities\\": "src/" 62 | } 63 | }, 64 | "autoload-dev": { 65 | "psr-4": { 66 | "TheFrosty\\WpUtilities\\Tests\\": "tests/unit/" 67 | } 68 | }, 69 | "scripts": { 70 | "install-codestandards": [ 71 | "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin::run" 72 | ], 73 | "phpcs": [ 74 | "bash ./bin/phpcs.sh --standard=phpcs-ruleset.xml" 75 | ], 76 | "phpstan": [ 77 | "bash ./bin/phpstan.sh" 78 | ], 79 | "phpunit": [ 80 | "./vendor/bin/phpunit --colors --coverage-html ./tests/results && php ./tests/clover-results.php ./tests/clover.xml 2" 81 | ], 82 | "psalm": [ 83 | "bash ./bin/psalm.sh" 84 | ], 85 | "tests": [ 86 | "@phpcs", 87 | "@phpunit" 88 | ] 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Api/Hash.php: -------------------------------------------------------------------------------- 1 | getHashedKey($encryption_key); 34 | $vector = \substr($this->getHashedKey(\sprintf('%s_iv', $encryption_key)), 0, 16); 35 | 36 | return \openssl_decrypt(\base64_decode($data), 'AES-256-CBC', $key, 0, $vector); 37 | } 38 | 39 | /** 40 | * Encrypt a string. 41 | * 42 | * @param string $data The string value to encrypt 43 | * @param string $encryption_key The encryption key. Example `SomeKeyWith4Delimiter|` _maybe_. 44 | * @return string 45 | */ 46 | protected function encrypt(string $data, string $encryption_key): string 47 | { 48 | $key = $this->getHashedKey($encryption_key); 49 | $vector = \substr($this->getHashedKey(\sprintf('%s_iv', $encryption_key)), 0, 16); 50 | 51 | return \base64_encode(\openssl_encrypt($data, 'AES-256-CBC', $key, 0, $vector)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Api/Instantiate.php: -------------------------------------------------------------------------------- 1 | buildClass($namespace, $class_name); 33 | if (!class_exists($class)) { 34 | return; 35 | } 36 | if (is_subclass_of($class, AbstractContainerProvider::class)) { 37 | $instance = new $class(); 38 | $instance->setContainer($this->getPlugin()->getContainer()); 39 | } 40 | $instance ??= new $class(); 41 | if ($instance instanceof WpHooksInterface) { 42 | $this->getPlugin()->add($instance); 43 | } 44 | $this->instantiated_order[get_called_class()][] = $instance; 45 | $this->addAction('after_setup_theme', function (): void { 46 | $this->getPlugin()->initialize(); // @phpstan-ignore-line 47 | }); 48 | } 49 | 50 | /** 51 | * Build the fully qualified class name. 52 | * @param string $namespace 53 | * @param string $class_name 54 | * @return string 55 | */ 56 | protected function buildClass(string $namespace, string $class_name): string 57 | { 58 | return sprintf('%s\\%s', $namespace, $this->getClassName($class_name)); 59 | } 60 | 61 | /** 62 | * Return the array of instantiated hooks in their respected instantiated order. 63 | * @param string $called_class The "Late Static Binding" class name 64 | * @return WpHooksInterface[] 65 | */ 66 | protected function getInstantiatedOrder(string $called_class): array 67 | { 68 | return $this->instantiated_order[$called_class] ?? []; 69 | } 70 | 71 | /** 72 | * Helper to get the PSR4 class name. 73 | * @param string $class_name 74 | * @return string 75 | */ 76 | private function getClassName(string $class_name): string 77 | { 78 | return str_replace(['_', '-'], '', ucwords($class_name, '_-')); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Api/ObjectRegistrarManager.php: -------------------------------------------------------------------------------- 1 | setPlugin($plugin); 27 | } 28 | 29 | /** 30 | * Add class hooks. 31 | */ 32 | public function addHooks(): void 33 | { 34 | $classes = $this->getObjectClasses(); 35 | array_walk($classes, [$this, 'instantiateClasses']); 36 | } 37 | 38 | /** 39 | * Get all registered objects from our filter. 40 | * @return array 41 | */ 42 | abstract public function getObjectClasses(): array; 43 | } 44 | -------------------------------------------------------------------------------- /src/Api/PageTemplates.php: -------------------------------------------------------------------------------- 1 | pageTemplates[$template->getFile()] = $template->getPath(); 46 | $this->wpTemplates[$this->getPrefix($template->getFile())] = $template->getDescription(); 47 | } 48 | } 49 | 50 | /** 51 | * Add class hooks. 52 | */ 53 | public function addHooks(): void 54 | { 55 | $this->addFilter('theme_page_templates', [$this, 'addNewTemplate']); 56 | $this->addFilter('template_include', [$this, 'templateInclude']); 57 | } 58 | 59 | /** 60 | * Adds our template(s) to the page dropdown. 61 | * @param string[] $posts_templates 62 | * @return array 63 | */ 64 | protected function addNewTemplate(array $posts_templates): array 65 | { 66 | if ($this->wpTemplates) { 67 | return \array_merge($posts_templates, $this->wpTemplates); 68 | } 69 | return $posts_templates; 70 | } 71 | 72 | /** 73 | * Checks if the template is assigned to the page 74 | * @param string $template 75 | * @return string 76 | */ 77 | protected function templateInclude(string $template): string 78 | { 79 | global $post; 80 | 81 | // Return the search template if we're searching (instead of the template for the first result) 82 | if (\is_search()) { 83 | return $template; 84 | } 85 | 86 | if (!$post instanceof \WP_Post) { 87 | return $template; 88 | } 89 | 90 | $page_template = \get_post_meta($post->ID, '_wp_page_template', true); 91 | if ( 92 | (!\is_string($page_template) || empty($page_template)) || 93 | !isset($this->wpTemplates[$page_template]) 94 | ) { 95 | return $template; 96 | } 97 | 98 | $page_template = \str_replace(self::PREFIX, '', $page_template); 99 | $filepath = \str_replace($page_template, '', $this->pageTemplates[$page_template]); 100 | 101 | $file = \sprintf('%s/%s', \untrailingslashit($filepath), $page_template); 102 | 103 | // Just to be safe, we check if the file exist first 104 | if (\file_exists($file)) { 105 | return $file; 106 | } 107 | 108 | // Return template 109 | return $template; 110 | } 111 | 112 | /** 113 | * Return a prefixed file. 114 | * @param string $file 115 | * @return string 116 | */ 117 | private function getPrefix(string $file): string 118 | { 119 | return \sprintf('%s%s', self::PREFIX, $file); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Api/RegistrarInterface.php: -------------------------------------------------------------------------------- 1 | handler->setTag($tag); 24 | } 25 | 26 | public function getTag(): string 27 | { 28 | return $this->tag; 29 | } 30 | 31 | public function getHandler(): HandlerInterface 32 | { 33 | return $this->handler; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Api/Shortcode/Handler/HandlerInterface.php: -------------------------------------------------------------------------------- 1 | str_replace((string)PHP_INT_MAX, '%#%', get_pagenum_link(PHP_INT_MAX)), 37 | 'current' => max(1, get_query_var('paged')), 38 | 'total' => $wp_query->max_num_pages, 39 | 'mid_size' => 5, 40 | 'prev_text' => __('«', 'wp-utilities'), 41 | 'next_text' => __('»', 'wp-utilities'), 42 | 'type' => 'list', 43 | ]); 44 | 45 | ob_start(); 46 | include 'templates/pagination.php'; 47 | return ob_get_clean(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Api/Shortcode/Handler/ShortcodeUiTrait.php: -------------------------------------------------------------------------------- 1 | ', 12 | '
    ', 13 | $paginate_links 14 | ); 15 | $paginate_links = str_replace( 16 | '
  • ', 17 | '
  • ', 18 | $paginate_links 19 | ); 20 | $paginate_links = str_replace( 21 | '
  • ', 22 | '
  • ', 23 | $paginate_links 24 | ); 25 | $paginate_links = str_replace('', '', $paginate_links); 26 | $paginate_links = str_replace( 27 | '
  • ', 28 | '
  • ', 29 | $paginate_links 30 | ); 31 | $paginate_links = preg_replace('/\s*page-numbers/', '', $paginate_links); 32 | 33 | // Display the pagination if more than one page is found. 34 | echo '
    ' . $paginate_links . '
    '; 35 | -------------------------------------------------------------------------------- /src/Api/Shortcode/ShortcodeInterface.php: -------------------------------------------------------------------------------- 1 | shortcode = $shortcode; 30 | } 31 | 32 | /** 33 | * Add class hooks. 34 | */ 35 | public function addHooks(): void 36 | { 37 | $this->addAction('init', [$this, 'addShortcode']); 38 | } 39 | 40 | /** 41 | * Register the shortcode. 42 | */ 43 | public function addShortcode(): void 44 | { 45 | add_shortcode($this->shortcode->getTag(), [$this->shortcode->getHandler(), 'handler']); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Api/TransientsTrait.php: -------------------------------------------------------------------------------- 1 | prefix; 43 | 44 | return $this->setQueryCacheKey( 45 | $key . substr($this->getHashedKey($input), 0, $this->wp_max_transient_chars - strlen($key)) 46 | ); 47 | } 48 | 49 | /** 50 | * Get the transient value. 51 | * @param string $transient Transient name. 52 | * @return mixed 53 | */ 54 | public function getTransient(string $transient): mixed 55 | { 56 | return get_transient($transient); 57 | } 58 | 59 | /** 60 | * Set the transient value. 61 | * @param string $transient Transient name. Expected to not be SQL-escaped. Must be 172 characters or fewer. 62 | * @param mixed $value Transient value. Must be serializable if non-scalar. Expected to not be SQL-escaped. 63 | * @param int $expiration Optional. Time until expiration in seconds. Default 0 (no expiration). 64 | * @return bool 65 | */ 66 | public function setTransient(string $transient, mixed $value, int $expiration = 0): bool 67 | { 68 | return set_transient($transient, $value, $expiration); 69 | } 70 | 71 | /** 72 | * Get the transient timeout value. 73 | * @param string $transient 74 | * @return int|null 75 | */ 76 | public function getTransientTimeout(string $transient): ?int 77 | { 78 | global $wpdb; 79 | $timeout = $wpdb->get_col( 80 | " 81 | SELECT option_value 82 | FROM $wpdb->options 83 | WHERE option_name 84 | LIKE '%_transient_timeout_$transient%'" 85 | ); 86 | return !isset($timeout[0]) || !is_numeric($timeout[0]) ? null : (int)$timeout[0]; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Api/WpCacheTrait.php: -------------------------------------------------------------------------------- 1 | queryCacheKey; 39 | } 40 | 41 | /** 42 | * Set the cache key for the current query. 43 | * @param string $queryCacheKey 44 | * @return string 45 | */ 46 | public function setQueryCacheKey(string $queryCacheKey): string 47 | { 48 | $this->queryCacheKey = $queryCacheKey; 49 | 50 | return $this->queryCacheKey; 51 | } 52 | 53 | /** 54 | * Get the cache group the current query. 55 | */ 56 | public function getCacheGroup(): string 57 | { 58 | return $this->queryCacheGroup ?? static::class; 59 | } 60 | 61 | /** 62 | * Optional. Set the cache group the current query. 63 | * @param string $group 64 | */ 65 | public function setCacheGroup(string $group): void 66 | { 67 | $this->queryCacheGroup = $group; 68 | } 69 | 70 | /** 71 | * Retrieve object from cache. 72 | * @param string $key The key under which to store the value. 73 | * @param string|null $group The group value appended to the $key. 74 | * @param bool $force Optional. Whether to force an update of the local cache from the persistent cache. Default 75 | * false. 76 | * @param bool $found Optional. Whether the key was found in the cache. Disambiguate a return of false, a storable 77 | * value. Passed by reference. Default null. 78 | * @return mixed Cached object value. 79 | * @SuppressWarnings(PHPMD.BooleanArgumentFlag) 80 | */ 81 | public function getCache(string $key, ?string $group = null, bool $force = false, ?bool &$found = null): mixed 82 | { 83 | return wp_cache_get($key, $group ?? $this->getCacheGroup(), $force, $found); 84 | } 85 | 86 | /** 87 | * Sets a value in cache. 88 | * @param string $key The key under which to store the value. 89 | * @param mixed $value The value to store. 90 | * @param string|null $group The group value appended to the $key. 91 | * @param int $expiration The expiration time, defaults to 0. 92 | * @return bool Returns TRUE on success or FALSE on failure. 93 | */ 94 | public function setCache(string $key, mixed $value, ?string $group = null, int $expiration = 0): bool 95 | { 96 | return wp_cache_set($key, $value, $group ?? $this->getCacheGroup(), $expiration); 97 | } 98 | 99 | /** 100 | * Deletes a value in cache. 101 | * @param string $key The key under which the value is stored. 102 | * @param string|null $group The group value appended to the $key. 103 | * @return bool Returns TRUE on success or FALSE on failure. 104 | */ 105 | public function deleteCache(string $key, ?string $group = null): bool 106 | { 107 | return wp_cache_delete($key, $group ?? $this->getCacheGroup()); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Api/WpQueryTrait.php: -------------------------------------------------------------------------------- 1 | getDefaults($post_type); 39 | 40 | return new WP_Query(wp_parse_args($args, $defaults)); 41 | } 42 | 43 | /** 44 | * Return a cached WP_Query object. 45 | * @param string $post_type 46 | * @param array $args Additional WP_Query parameters. 47 | * @param int|null $expiration The expiration time, defaults to `MINUTE_IN_SECONDS`. 48 | * @return WP_Query 49 | */ 50 | protected function wpQueryCached(string $post_type, array $args = [], ?int $expiration = null): WP_Query 51 | { 52 | $args = wp_parse_args($args, $this->getDefaults($post_type)); 53 | $cache_key = $this->setQueryCacheKey( 54 | $this->getHashedKey( 55 | sprintf('%s/query_%s', Plugin::TAG, $this->getHashedKey(json_encode($args))) 56 | ) 57 | ); 58 | $query = $this->getCache($cache_key); 59 | if (!($query instanceof WP_Query)) { 60 | $query = $this->wpQuery($post_type, $args); 61 | if ($query->have_posts()) { 62 | $this->setCache($cache_key, $query, $this->getCacheGroup(), $expiration ?? MINUTE_IN_SECONDS); 63 | wp_reset_postdata(); 64 | } 65 | } 66 | 67 | return $query; 68 | } 69 | 70 | /** 71 | * Return an array of WP_Query post ID's. 72 | * @param string $post_type 73 | * @param array $args Additional WP_Query parameters. 74 | * @param int|null $expiration Deprecated, use `$this->wpQueryGetAllIdsCached()` instead. 75 | * @return int[] An array of all post type IDs 76 | */ 77 | protected function wpQueryGetAllIds(string $post_type, array $args = [], ?int $expiration = null): array 78 | { 79 | if (is_int($expiration)) { 80 | _deprecated_argument( 81 | __FUNCTION__, 82 | '2.4.0', 83 | esc_html__( // phpcs:disable Generic.Files.LineLength.TooLong 84 | 'Usage of expiration is deprecated. Use `WpQueryTrait::wpQueryGetAllIdsCached` if cache is desired.', 85 | 'wp-utilities' 86 | ) // phpcs:enable 87 | ); 88 | } 89 | 90 | return $this->wpGetAllIds([$this, 'wpQuery'], $post_type, $args, $expiration); 91 | } 92 | 93 | /** 94 | * Return an array of cached WP_Query post ID's. 95 | * @param string $post_type 96 | * @param array $args Additional WP_Query parameters. 97 | * @param int|null $expiration The expiration time, defaults to `MINUTE_IN_SECONDS`. 98 | * @return int[] An array of all post type IDs 99 | */ 100 | protected function wpQueryGetAllIdsCached(string $post_type, array $args = [], ?int $expiration = null): array 101 | { 102 | return $this->wpGetAllIds([$this, 'wpQueryCached'], $post_type, $args, $expiration); 103 | } 104 | 105 | /** 106 | * Return an array of cached WP_Query post ID's. This will do a large loop to get *all* posts within 107 | * the `$post_type`. So when you are aware of thousands of posts, and might need them all use this method. 108 | * @param callable $callback 109 | * @param string $post_type 110 | * @param array $args Additional WP_Query parameters. 111 | * @param int|null $expiration The expiration time, defaults to `MINUTE_IN_SECONDS`. 112 | * @return int[] An array of all post type IDs 113 | * @SuppressWarnings(PHPMD.UndefinedVariable) 114 | */ 115 | private function wpGetAllIds( 116 | callable $callback, 117 | string $post_type, 118 | array $args = [], 119 | ?int $expiration = null 120 | ): array { 121 | $paged = 0; 122 | $post_ids = []; 123 | do { 124 | $defaults = [ 125 | 'fields' => 'ids', 126 | 'posts_per_page' => 100, 127 | 'no_found_rows' => false, // We need pagination & the count for all posts found. 128 | 'paged' => $paged++, // phpcs:ignore 129 | 'update_post_term_cache' => false, 130 | 'update_post_meta_cache' => false, 131 | ]; 132 | $query = call_user_func($callback, $post_type, wp_parse_args($args, $defaults), $expiration); 133 | if ($query instanceof WP_Query && $query->have_posts()) { 134 | foreach ($query->posts as $id) { 135 | $post_ids[] = $id; 136 | } 137 | } 138 | } while ($query->max_num_pages > $paged); 139 | 140 | return $post_ids; 141 | } 142 | 143 | /** 144 | * Get the default WP_Query arguments and allow them to be filtered 145 | * @param string|null $post_type The post_type 146 | * @return array 147 | */ 148 | private function getDefaults(?string $post_type = null): array 149 | { 150 | return array_filter( 151 | apply_filters( 152 | sprintf('%s/wp_query_defaults', Plugin::TAG), 153 | [ 154 | 'post_type' => $post_type, 155 | 'posts_per_page' => 100, 156 | 'post_status' => ['publish', 'future', 'draft'], 157 | 'ignore_sticky_posts' => true, 158 | 'no_found_rows' => true, 159 | ], 160 | $post_type 161 | ) 162 | ); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Api/WpRemote.php: -------------------------------------------------------------------------------- 1 | $function(esc_url($url), $this->buildRequestArgs($args))); 45 | if (!is_wp_error($response) && $response !== '') { 46 | $body = json_decode($response); 47 | if ($body === null) { 48 | return false; 49 | } 50 | } 51 | 52 | return $body ?? false; 53 | } 54 | 55 | /** 56 | * Get the remote GET request body cached. 57 | * @param string $url 58 | * @param int|null $expiration 59 | * @param string|null $user_agent 60 | * @param string|null $version 61 | * @return false|mixed 62 | */ 63 | public function retrieveBodyCached( 64 | string $url, 65 | ?int $expiration = 0, 66 | ?string $user_agent = null, 67 | ?string $version = null 68 | ): mixed { 69 | $key = $this->getHashedKey($url); 70 | $body = $this->getCache($key); 71 | if (empty($body)) { 72 | if ($user_agent !== null) { 73 | $args = [ 74 | 'user-agent' => esc_attr( 75 | sprintf( 76 | '%s/%s; %s', 77 | $user_agent, 78 | $version ?? $GLOBALS['wp_version'], 79 | get_bloginfo('url') 80 | ) 81 | ), 82 | ]; 83 | } 84 | $body = $this->retrieveBody($url, $args ?? []); 85 | if (!empty($body)) { 86 | $this->setCache($key, $body, null, $expiration ?? DAY_IN_SECONDS); 87 | } 88 | 89 | return $body; 90 | } 91 | 92 | return $body; 93 | } 94 | 95 | /** 96 | * Return a remote GET request. 97 | * @param string $url 98 | * @param array $args 99 | * @return array|\WP_Error 100 | */ 101 | public function wpRemoteGet(string $url, array $args = []): \WP_Error|array 102 | { 103 | return wp_remote_get(esc_url($url), $this->buildRequestArgs($args)); 104 | } 105 | 106 | /** 107 | * Return a remote POST request. 108 | * @param string $url 109 | * @param array $args 110 | * @return array|\WP_Error 111 | */ 112 | public function wpRemotePost(string $url, array $args = []): \WP_Error|array 113 | { 114 | return wp_remote_post(esc_url($url), $this->buildRequestArgs($args)); 115 | } 116 | 117 | /** 118 | * Build Request args. 119 | * @param array $args 120 | * @return array 121 | */ 122 | private function buildRequestArgs(array $args): array 123 | { 124 | $defaults = [ 125 | 'timeout' => apply_filters(Plugin::TAG . 'wp_remote_timeout', 15), 126 | ]; 127 | 128 | return array_filter(array_merge($defaults, $args)); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Exceptions/Exception.php: -------------------------------------------------------------------------------- 1 | populate($fields); 30 | } 31 | 32 | /** 33 | * Optional. Implement customized getCustomDelimiters() to return values 34 | * to search and replace for getMethod(). 35 | * @return array 36 | */ 37 | public function getCustomDelimiters(): array 38 | { 39 | return []; 40 | } 41 | 42 | /** 43 | * Optional method to get a model as an array. 44 | * Default implementation is to engage fields listed in getSerializableFields(). 45 | * You should implement customized toArray() if you are using logic different from described in 46 | * getSerializableFields() in child classes. 47 | * @return array 48 | * @throws Exception 49 | */ 50 | public function toArray(): array 51 | { 52 | if (!empty($this->getSerializableFields())) { 53 | $result = []; 54 | 55 | foreach ($this->getSerializableFields() as $field_name) { 56 | $method = $this->getGetMethod($field_name); 57 | if (!method_exists($this, $method)) { 58 | continue; 59 | } 60 | $value = $this->$method(); 61 | if (is_object($value) && method_exists($value, 'toArray')) { 62 | $result[$field_name] = $value->toArray(); 63 | continue; 64 | } 65 | if ( 66 | (is_array($value) && !empty($value[0])) && 67 | (is_object($value[0]) && method_exists($value[0], 'toArray')) 68 | ) { 69 | $result[$field_name] = $this->toArrayDeep($value); 70 | continue; 71 | } 72 | $result[$field_name] = $value; 73 | } 74 | 75 | return $result; 76 | } 77 | throw new Exception( 78 | 'If you are going to use toArray() in your model you have 79 | to implement custom logic or return a list of fields in getSerializableFields().' 80 | ); 81 | } 82 | 83 | /** 84 | * Method to convert an array of BaseModels to an array of BaseModel objects converted to an 85 | * array. 86 | * 87 | * This is useful in situations where you have an array of BaseModels and need to convert it 88 | * into an array for the purpose of sending it to the front end through WordPress' localize 89 | * script functionality. 90 | * 91 | * @param BaseModel[] $models 92 | * @return array 93 | * @throws Exception 94 | */ 95 | public function toArrayDeep(array $models): array 96 | { 97 | $deep_array = []; 98 | foreach ($models as $model) { 99 | $deep_array[] = $model->toArray(); 100 | } 101 | 102 | return $deep_array; 103 | } 104 | 105 | /** 106 | * Get datetime fields. 107 | * @return array 108 | */ 109 | protected function getDateTimeFields(): array 110 | { 111 | return []; 112 | } 113 | 114 | /** 115 | * Get the fields to be used in toArray() 116 | * Field names should be in camelCase (ex. propertyName) so that getPropertyName could easily be called 117 | * @return array 118 | */ 119 | protected function getSerializableFields(): array 120 | { 121 | return []; 122 | } 123 | 124 | /** 125 | * Populate model. 126 | * @param array $fields 127 | */ 128 | protected function populate(array $fields): void 129 | { 130 | foreach ($fields as $field => $value) { 131 | // If field value is null we just leave it blank 132 | if (is_null($value)) { 133 | continue; 134 | } 135 | 136 | $setter_method = $this->getSetterMethod($field); 137 | $populate_method = $this->getPopulateMethod($field); 138 | 139 | // First try to proceed with custom population logic 140 | if (method_exists($this, $populate_method)) { 141 | $this->$populate_method($value); 142 | // If no custom logic found proceed with regular setters 143 | } elseif (method_exists($this, $setter_method)) { 144 | // Should we convert it to datetime? 145 | if (in_array($field, $this->getDateTimeFields(), true)) { 146 | $value = date_create($value); 147 | } 148 | $this->$setter_method($value); 149 | } 150 | } 151 | } 152 | 153 | /** 154 | * Gets the 'get' method. 155 | * @param string $field 156 | * @return string 157 | */ 158 | protected function getGetMethod(string $field): string 159 | { 160 | return $this->getMethod('get', $field); 161 | } 162 | 163 | /** 164 | * Gets the 'populate' method. 165 | * @param string $field 166 | * @return string 167 | */ 168 | private function getPopulateMethod(string $field): string 169 | { 170 | return $this->getMethod('populate', $field); 171 | } 172 | 173 | /** 174 | * Gets the 'set' method. 175 | * @param string $field 176 | * @return string 177 | */ 178 | private function getSetterMethod(string $field): string 179 | { 180 | return $this->getMethod('set', $field); 181 | } 182 | 183 | /** 184 | * Helper to get the method with prefix. 185 | * @param string $prefix 186 | * @param string $field 187 | * @return string 188 | */ 189 | private function getMethod(string $prefix, string $field): string 190 | { 191 | $search = \array_merge(['_', '-'], $this->getCustomDelimiters()); 192 | $delimiters = '_-'; 193 | $delimiters .= !empty($this->getCustomDelimiters()) ? \implode('', $this->getCustomDelimiters()) : ''; 194 | 195 | return $prefix . \str_replace($search, '', \ucwords($field, $delimiters)); 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/Models/PageTemplate.php: -------------------------------------------------------------------------------- 1 | description; 39 | } 40 | 41 | protected function setDescription(string $description): void 42 | { 43 | $this->description = $description; 44 | } 45 | 46 | public function getFile(): string 47 | { 48 | return $this->file; 49 | } 50 | 51 | protected function setFile(string $file): void 52 | { 53 | $this->file = $file; 54 | } 55 | 56 | public function getPath(): string 57 | { 58 | return $this->path; 59 | } 60 | 61 | protected function setPath(string $path): void 62 | { 63 | $this->path = $path; 64 | } 65 | 66 | /** 67 | * Get serializable fields. 68 | * @return string[] 69 | */ 70 | protected function getSerializableFields(): array 71 | { 72 | return [ 73 | self::FIELD_DESC, 74 | self::FIELD_FILE, 75 | self::FIELD_PATH, 76 | ]; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Models/WpQuery/QueryArgs.php: -------------------------------------------------------------------------------- 1 | 'post', 8 | 'category_something' => 'does this accept an integer or a string?', 9 | 'number_of_...errr' 10 | ] ); 11 | ``` 12 | 13 | This library provides well-documented classes which represent some of the array-type parameters that are used in 14 | WordPress. Using these classes at the point where you're constructing the arguments to pass into a WordPress 15 | function means you get familiar autocompletion and intellisense in your code editor. 16 | 17 | ## Usage 18 | 19 | ```php 20 | use TheFrosty\WpUtilities\Models\WpQuery\QueryArgs; 21 | $args = new QueryArgs(); 22 | $args->tag = 'amazing'; 23 | $args->posts_per_page = 100; 24 | 25 | $query = new \WP_Query($args->toArray()); 26 | ``` 27 | -------------------------------------------------------------------------------- /src/Models/WpScript/Args.php: -------------------------------------------------------------------------------- 1 | setContainer($container)`. 20 | */ 21 | public function __construct(?ContainerInterface $container = null) 22 | { 23 | $this->setContainer($container); 24 | } 25 | 26 | /** 27 | * Registers hooks for the plugin. 28 | */ 29 | abstract public function addHooks(): void; 30 | } 31 | -------------------------------------------------------------------------------- /src/Plugin/AbstractHookProvider.php: -------------------------------------------------------------------------------- 1 | = v2.0.0. 14 | * @ref https://github.com/php-fig/container/blob/2.0.0/src/ContainerInterface.php 15 | * @package TheFrosty\WpUtilities\Plugin 16 | */ 17 | class Container extends Pimple implements ContainerInterface 18 | { 19 | 20 | /** 21 | * Finds an entry of the container by its identifier and returns it. 22 | * 23 | * @param string $id Identifier of the entry to look for. 24 | * 25 | * @return mixed Entry. 26 | * @throws ContainerExceptionInterface Error while retrieving the entry. 27 | * @throws NotFoundExceptionInterface No entry was found for **this** identifier. 28 | */ 29 | #[ReturnTypeWillChange] 30 | public function get(string $id) 31 | { 32 | return $this->offsetGet($id); 33 | } 34 | 35 | /** 36 | * Returns true if the container can return an entry for the given identifier. 37 | * Returns false otherwise. 38 | * 39 | * `has($id)` returning true does not mean that `get($id)` will not throw an exception. 40 | * It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`. 41 | * 42 | * @param string $id Identifier of the entry to look for. 43 | * @return bool 44 | */ 45 | #[ReturnTypeWillChange] 46 | public function has(string $id): bool 47 | { 48 | return $this->offsetExists($id); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Plugin/Container100.php: -------------------------------------------------------------------------------- 1 | offsetGet($id); 31 | } 32 | 33 | /** 34 | * Returns true if the container can return an entry for the given identifier. 35 | * Returns false otherwise. 36 | * 37 | * `has($id)` returning true does not mean that `get($id)` will not throw an exception. 38 | * It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`. 39 | * 40 | * @param string $id Identifier of the entry to look for. 41 | * @return bool 42 | */ 43 | public function has($id) 44 | { 45 | return $this->offsetExists($id); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Plugin/Container110.php: -------------------------------------------------------------------------------- 1 | offsetGet($id); 31 | } 32 | 33 | /** 34 | * Returns true if the container can return an entry for the given identifier. 35 | * Returns false otherwise. 36 | * 37 | * `has($id)` returning true does not mean that `get($id)` will not throw an exception. 38 | * It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`. 39 | * 40 | * @param string $id Identifier of the entry to look for. 41 | * @return bool 42 | */ 43 | public function has(string $id) 44 | { 45 | return $this->offsetExists($id); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Plugin/ContainerAwareTrait.php: -------------------------------------------------------------------------------- 1 | container && $this->container->get($name); 33 | } 34 | 35 | /** 36 | * Whether a container service exists. 37 | * 38 | * @param string $name Service name. 39 | * @return bool 40 | */ 41 | public function __isset(string $name): bool 42 | { 43 | return $this->container && $this->container->has($name); 44 | } 45 | 46 | /** 47 | * Calling a non-existent method on the class checks to see if there's an 48 | * item in the container that is callable and if so, calls it. 49 | * 50 | * @param string $method Method name. 51 | * @param array $args Method arguments. 52 | * @return mixed 53 | */ 54 | public function __call(string $method, array $args) 55 | { 56 | if ($this->container && $this->container->has($method)) { 57 | $object = $this->container->get($method); 58 | if (\is_callable($object)) { 59 | return \call_user_func_array($object, $args); 60 | } 61 | } 62 | 63 | return false; 64 | } 65 | 66 | /** 67 | * Enable access to the DI container by plugin consumers. 68 | * 69 | * @return ContainerInterface|null 70 | */ 71 | public function getContainer(): ?ContainerInterface 72 | { 73 | return $this->container; 74 | } 75 | 76 | /** 77 | * Set the container. 78 | * @param ContainerInterface|null $container Dependency injection container. 79 | * @return ContainerAwareTrait|AbstractContainerProvider|Plugin 80 | */ 81 | public function setContainer(?ContainerInterface $container = null): self 82 | { 83 | $this->container = $container; 84 | 85 | return $this; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Plugin/HooksTrait.php: -------------------------------------------------------------------------------- 1 | mapFilter($this->getWpFilterId($hook, $method, $priority), $method, $arg_count), 35 | $priority, 36 | $arg_count 37 | ); 38 | 39 | return $filter === true; 40 | } 41 | 42 | /** 43 | * Register a filter to run exactly one time. 44 | * 45 | * The arguments match that of add_filter(), but this function will also register a second 46 | * callback designed to remove the first immediately after it runs. 47 | * 48 | * @param string $hook The name of the filter to hook the $function_to_add callback to. 49 | * @param callable $method he callback to be run when the filter is applied. 50 | * @param int $priority Optional. Used to specify the order in which the functions 51 | * associated with a particular action are executed. Default 10. 52 | * @param int $arg_count Optional. The number of arguments the function accepts. Default 1. 53 | * @return bool 54 | */ 55 | protected function addFilterOnce(string $hook, callable $method, int $priority = 10, int $arg_count = 1): bool 56 | { 57 | $singular = function () use ($hook, $method, $priority, $arg_count, &$singular): \Closure { 58 | $filter = $this->mapFilter($this->getWpFilterId($hook, $method, $priority), $method, $arg_count); 59 | $this->removeFilter($hook, $singular, $priority); 60 | 61 | return $filter; 62 | }; 63 | 64 | return $this->addFilter($hook, $singular, $priority, $arg_count); 65 | } 66 | 67 | /** 68 | * Add a WordPress action. 69 | * 70 | * This is an alias of add_filter(). 71 | * 72 | * @param string $hook The name of the filter to hook the $function_to_add callback to. 73 | * @param callable $method he callback to be run when the filter is applied. 74 | * @param int $priority Optional. Used to specify the order in which the functions 75 | * associated with a particular action are executed. Default 10. 76 | * @param int $arg_count Optional. The number of arguments the function accepts. Default 1. 77 | * @return bool true 78 | */ 79 | protected function addAction(string $hook, callable $method, int $priority = 10, int $arg_count = 1): bool 80 | { 81 | return $this->addFilter($hook, $method, $priority, $arg_count); 82 | } 83 | 84 | /** 85 | * Register an action to run exactly one time. 86 | * 87 | * The arguments match that of add_action(), but this function will also register a second 88 | * callback designed to remove the first immediately after it runs. 89 | * 90 | * @param string $hook The name of the filter to hook the $function_to_add callback to. 91 | * @param callable $method he callback to be run when the filter is applied. 92 | * @param int $priority Optional. Used to specify the order in which the functions 93 | * associated with a particular action are executed. Default 10. 94 | * @param int $arg_count Optional. The number of arguments the function accepts. Default 1. 95 | * @return bool 96 | */ 97 | protected function addActionOnce(string $hook, callable $method, int $priority = 10, int $arg_count = 1): bool 98 | { 99 | $singular = function () use ($hook, $method, $priority, $arg_count, &$singular): \Closure { 100 | $filter = $this->mapFilter($this->getWpFilterId($hook, $method, $priority), $method, $arg_count); 101 | $this->removeAction($hook, $singular, $priority); 102 | 103 | return $filter; 104 | }; 105 | 106 | return $this->addAction($hook, $singular, $priority, $arg_count); 107 | } 108 | 109 | /** 110 | * Remove a WordPress filter. 111 | * 112 | * @param string $hook The name of the filter to hook the $function_to_add callback to. 113 | * @param callable $method he callback to be run when the filter is applied. 114 | * @param int $priority Optional. Used to specify the order in which the functions 115 | * associated with a particular action are executed. Default 10. 116 | * @param int $arg_count Optional. The number of arguments the function accepts. Default 1. 117 | * @return bool Whether the function existed before it was removed. 118 | */ 119 | protected function removeFilter(string $hook, callable $method, int $priority = 10, int $arg_count = 1): bool 120 | { 121 | return \remove_filter( 122 | $hook, 123 | $this->mapFilter($this->getWpFilterId($hook, $method, $priority), $method, $arg_count), 124 | $priority 125 | ); 126 | } 127 | 128 | /** 129 | * Remove a WordPress action. 130 | * 131 | * This is an alias of remove_filter(). 132 | * 133 | * @param string $hook The name of the filter to hook the $function_to_add callback to. 134 | * @param callable $method he callback to be run when the filter is applied. 135 | * @param int $priority Optional. Used to specify the order in which the functions 136 | * associated with a particular action are executed. Default 10. 137 | * @param int $arg_count Optional. The number of arguments the function accepts. Default 1. 138 | * @return bool Whether the function is removed. 139 | */ 140 | protected function removeAction(string $hook, callable $method, int $priority = 10, int $arg_count = 1): bool 141 | { 142 | return $this->removeFilter($hook, $method, $priority, $arg_count); 143 | } 144 | 145 | /** 146 | * Run do_action. 147 | * 148 | * @param string $action The action to run 149 | * @param array ...$args Any extra arguments to pass to do_action 150 | */ 151 | protected function doAction(string $action, array ...$args): void 152 | { 153 | \do_action($action, ...$args); 154 | } 155 | 156 | /** 157 | * Run apply_filters. 158 | * 159 | * @param string $filter The filter to run 160 | * @param mixed $value The value to filter 161 | * @param array ...$args Any extra values to send through the filter 162 | * @return mixed 163 | */ 164 | protected function applyFilters(string $filter, mixed $value, array ...$args): mixed 165 | { 166 | return \apply_filters($filter, $value, ...$args); 167 | } 168 | 169 | /** 170 | * Get a unique ID for a hook based on the internal method, hook, and priority. 171 | * 172 | * @param string $hook The name of the filter to hook the $function_to_add callback to. 173 | * @param callable $method he callback to be run when the filter is applied. 174 | * @param int $priority Optional. Used to specify the order in which the functions 175 | * associated with a particular action are executed. Default 10. 176 | * @return string 177 | */ 178 | protected function getWpFilterId(string $hook, callable $method, int $priority): string 179 | { 180 | return \_wp_filter_build_unique_id($hook, $method, $priority); 181 | } 182 | 183 | /** 184 | * Map a filter to a closure that inherits the class' internal scope. 185 | * 186 | * This allows hooks to use protected and private methods. 187 | * 188 | * @param string $filter_id The name of the filter to hook the $function_to_add callback to. 189 | * @param callable $method he callback to be run when the filter is applied. 190 | * @param int $arg_count Optional. The number of arguments the function accepts. Default 1. 191 | * @return \Closure The callable actually attached to a WP hook 192 | */ 193 | private function mapFilter(string $filter_id, callable $method, int $arg_count = 1): \Closure 194 | { 195 | if (empty($this->filter_map[$filter_id])) { 196 | $this->filter_map[$filter_id] = function () use ($method, $arg_count) { 197 | return \call_user_func_array($method, \array_slice(\func_get_args(), 0, $arg_count)); 198 | }; 199 | } 200 | 201 | return $this->filter_map[$filter_id]; 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/Plugin/HttpFoundationRequestInterface.php: -------------------------------------------------------------------------------- 1 | wp_hooks[] = $wp_hooks; 40 | 41 | if ($wp_hooks instanceof PluginAwareInterface) { 42 | $wp_hooks->setPlugin($plugin); 43 | } 44 | 45 | if ($wp_hooks instanceof HttpFoundationRequestInterface) { 46 | $wp_hooks->setRequest(); 47 | } 48 | 49 | return $plugin; 50 | } 51 | 52 | /** 53 | * All the methods that need to be performed upon plugin initialization should 54 | * be done here. 55 | */ 56 | public function initialize(): void 57 | { 58 | foreach ($this as $wp_hook) { 59 | if ($wp_hook instanceof WpHooksInterface && !array_key_exists($wp_hook::class, $this->initiated)) { 60 | $this->initiated[$wp_hook::class] = true; 61 | $wp_hook->addHooks(); 62 | } 63 | } 64 | } 65 | 66 | /** 67 | * Provides an iterator over the $wp_hooks property. 68 | * @return ArrayIterator 69 | */ 70 | public function getIterator(): ArrayIterator 71 | { 72 | return new ArrayIterator($this->wp_hooks); 73 | } 74 | 75 | /** 76 | * Gets the array of registered WpHooksInterface objects. 77 | * @return WpHooksInterface[] 78 | */ 79 | public function getWpHooks(): array 80 | { 81 | return $this->wp_hooks; 82 | } 83 | 84 | /** 85 | * Return the instance of the hook. 86 | * @param string $class_name 87 | * @return WpHooksInterface|null 88 | */ 89 | public function getWpHookObject(string $class_name): ?WpHooksInterface 90 | { 91 | $wp_hooks = $this->getWpHooks(); 92 | foreach ($wp_hooks as $key => $object) { 93 | if ($object::class === $class_name) { 94 | return $object[$key]; 95 | } 96 | } 97 | 98 | return null; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Plugin/Plugin.php: -------------------------------------------------------------------------------- 1 | plugin; 24 | } 25 | 26 | /** 27 | * Set the main plugin instance. 28 | * 29 | * @param PluginInterface $plugin Main plugin instance. 30 | * @return PluginInterface 31 | */ 32 | public function setPlugin(PluginInterface $plugin): PluginInterface 33 | { 34 | $this->plugin = $plugin; 35 | 36 | return $plugin; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Plugin/PluginFactory.php: -------------------------------------------------------------------------------- 1 | setInit(new Init()) 56 | ->setBasename(\plugin_basename($filename)) 57 | ->setDirectory(\plugin_dir_path($filename)) 58 | ->setFile($filename) 59 | ->setSlug($slug) 60 | ->setUrl(\plugin_dir_url($filename)); 61 | 62 | $plugin = self::setContainer($plugin); 63 | $plugin->setTemplateLoader(new TemplateLoader($plugin)); 64 | self::$instances[$slug] = $plugin; 65 | 66 | return $plugin; 67 | } 68 | 69 | /** 70 | * Set the Pimple\Container if it's available. 71 | * @param Plugin $plugin 72 | * @return Plugin 73 | */ 74 | private static function setContainer(Plugin $plugin): Plugin 75 | { 76 | if ( 77 | \class_exists('\Pimple\Container') && 78 | \interface_exists('\Psr\Container\ContainerInterface') 79 | ) { 80 | $container = self::getContainer(); 81 | $plugin->setContainer($container); 82 | $container[$plugin->getSlug()] = $plugin; 83 | } 84 | 85 | return $plugin; 86 | } 87 | 88 | /** 89 | * Return a "Container" stub for ContainerInterface v1.0.0, v1.1.0 and/or >= v2.0.0. 90 | * @return ContainerInterface 91 | */ 92 | private static function getContainer(): ContainerInterface 93 | { 94 | $reflection = new ReflectionMethod('\Psr\Container\ContainerInterface', 'has'); 95 | if ($reflection->hasReturnType() && $reflection->getReturnType()) { 96 | return new Container(); // ContainerInterface >= 2.0.0 97 | } 98 | $parameter = $reflection->getParameters()[0] ?? null; 99 | if ($parameter && $parameter->getType() && $parameter->getType()->getName() === 'string') { 100 | return new Container110(); 101 | } 102 | 103 | return new Container100(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Plugin/Provider/I18n.php: -------------------------------------------------------------------------------- 1 | loadTextDomain(); 31 | 32 | return; 33 | } 34 | $this->addAction('init', [$this, 'loadTextDomain']); 35 | } 36 | 37 | /** 38 | * Load the text domain to localize the plugin. 39 | */ 40 | protected function loadTextDomain(): void 41 | { 42 | load_plugin_textdomain( 43 | $this->getPlugin()->getSlug(), 44 | false, 45 | dirname($this->getPlugin()->getBasename()) . '/languages' 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Plugin/README.md: -------------------------------------------------------------------------------- 1 | # Plugin 2 | 3 | ## Adding a new hook provider object 4 | 5 | Initiate the factory like so: 6 | 7 | ```php 8 | use TheFrosty\WpUtilities\Plugin\PluginFactory; 9 | ( PluginFactory::create( 'slug' ) ) 10 | ->add( new Members() ) // Class extends `AbstractHookProvider` or implements `WpHooksInterface` 11 | ->add( new SomeOtherClass() ) 12 | ->initialize(); 13 | ``` 14 | 15 | ...or: 16 | 17 | ```php 18 | use TheFrosty\WpUtilities\Plugin\PluginFactory; 19 | $plugin = PluginFactory::create( 'slug' ); 20 | $plugin 21 | ->add( new SomeOtherClass() ) 22 | ->add( new SomeOtherNewClass() ) 23 | ->initialize(); 24 | ``` 25 | 26 | You can also use the latter statement with conditions available on `plugins_loaded` (or use the new `addIfCondition` method) like: 27 | 28 | ```php 29 | /** @var heFrosty\WpUtilities\Plugin\Plugin $plugin */ 30 | if ( \is_customize_preview() ) { 31 | $plugin 32 | ->add( new SomeOtherCustomizeClass() ) 33 | ->initialize(); 34 | } 35 | ``` 36 | 37 | If you'd like to initialize a class on a specific action hook use `addOnHook()` like: 38 | 39 | ```php 40 | $plugin 41 | ->add( new SomeOtherClass() ) 42 | ->addOnHook( SomeClassToLoad::class, $tag = 'admin_init', $priority = 10, $admin_only = true ) 43 | ->initialize(); 44 | ``` 45 | 46 | If you'd like to initialize a class on a specific action hook use and meet a condition `addOnCondition()` like: 47 | 48 | ```php 49 | $plugin 50 | ->add( new SomeOtherClass() ) 51 | ->addOnCondition( SomeClassToLoad::class, $condition = static function() { return true; }, $tag = 'admin_init', $priority = 10, $admin_only = true ) 52 | ->initialize(); 53 | ``` 54 | 55 | If you'd like to initialize a class right away if a condition is met use `addIfCondition()` like: 56 | 57 | ```php 58 | $plugin 59 | ->add( new SomeOtherClass() ) 60 | ->addOnCondition( SomeClassToLoad::class, $condition = \class_exists( 'SomeClassThatIsRequired' ) ) 61 | ->initialize(); 62 | ``` 63 | -------------------------------------------------------------------------------- /src/Plugin/TemplateLoader.php: -------------------------------------------------------------------------------- 1 | plugin = $plugin; 45 | } 46 | 47 | /** 48 | * Clean up template data. 49 | */ 50 | public function __destruct() 51 | { 52 | $this->unsetTemplateData(); 53 | } 54 | 55 | /** 56 | * Helper to get the data 57 | * @param string|null $var 58 | * 59 | * @return mixed 60 | */ 61 | public static function getData(?string $var = null) 62 | { 63 | return \get_query_var($var ?? self::VAR, []); 64 | } 65 | 66 | /** 67 | * Make custom data available to template. 68 | * 69 | * Data is available to the template as properties under the variable passed to '$var_name'. 70 | * 71 | * @param array $data Custom data for the template. 72 | * @param string|null $var The default var name. 73 | * 74 | * @return $this 75 | */ 76 | public function setTemplateData(array $data = [], ?string $var = null): self 77 | { 78 | if (!empty($data)) { 79 | \set_query_var($var ?? self::VAR, $data); 80 | } 81 | 82 | // Add $var_name to custom variable store if not default value 83 | if (!\is_null($var) && $var !== self::VAR) { 84 | $this->template_data_var_names[] = $var; 85 | } 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * Return a template part. 92 | * 93 | * @param string $slug Template slug. 94 | * @param string|null $name Optional. Template variation name. Default null. 95 | * 96 | * @return string URI string to the template path file. 97 | * @throws \Exception 98 | */ 99 | public function getTemplatePart(string $slug, ?string $name = null): string 100 | { 101 | \do_action(Plugin::TAG . 'template_loader/get_template_part_' . $slug, $slug, $name); 102 | $templates = $this->getTemplateFileNames($slug, $name); 103 | 104 | return $this->getTemplate($templates); 105 | } 106 | 107 | /** 108 | * Retrieve a template part. 109 | * 110 | * @param string $slug Template slug. 111 | * @param string|null $name Optional. Template variation name. Default null. 112 | * 113 | * @throws \Exception 114 | */ 115 | public function loadTemplatePart(string $slug, ?string $name = null): void 116 | { 117 | \do_action(Plugin::TAG . 'template_loader/get_template_part_' . $slug, $slug, $name); 118 | $templates = $this->getTemplateFileNames($slug, $name); 119 | $this->loadTemplate($templates); 120 | } 121 | 122 | /** 123 | * Retrieve the name of the highest priority template file that exists and returns it. 124 | * 125 | * @param array $template_names Template file(s) to search for, in order. 126 | * 127 | * @return string 128 | * @throws \Exception 129 | */ 130 | protected function getTemplate(array $template_names): string 131 | { 132 | try { 133 | return $this->getLocatedFile($template_names); 134 | } catch (\Exception $exception) { 135 | throw $exception; 136 | } 137 | } 138 | 139 | /** 140 | * Retrieve the name of the highest priority template file that exists and loads it. 141 | * 142 | * @param array $template_names Template file(s) to search for, in order. 143 | * 144 | * @throws \Exception 145 | */ 146 | protected function loadTemplate(array $template_names): void 147 | { 148 | try { 149 | $located = $this->getLocatedFile($template_names); 150 | \load_template($located, false); 151 | } catch (\Exception $exception) { 152 | throw $exception; 153 | } 154 | } 155 | 156 | /** 157 | * Return a list of paths to check for template locations. 158 | * 159 | * Since we do not expect to support templates in theme overriding the plugin's 160 | * templates, we only check for templates in the plugin. It is possible to 161 | * add template directories through 162 | * 163 | * @return array 164 | */ 165 | protected function getTemplatePaths(): array 166 | { 167 | $file_paths = [ 168 | 100 => $this->plugin->getPath('/views'), 169 | ]; 170 | 171 | /** 172 | * Allow ordered list of template paths to be amended. 173 | * 174 | * @param array $file_paths Default is directory in child theme at index 1, 175 | * parent theme at 10, and plugin at 100. 176 | */ 177 | $file_paths = \apply_filters(Plugin::TAG . 'template_loader/template_paths', $file_paths); 178 | 179 | // Sort the file paths based on priority. 180 | \ksort($file_paths, \SORT_NUMERIC); 181 | 182 | return \array_map('\trailingslashit', $file_paths); 183 | } 184 | 185 | /** 186 | * Remove access to custom data in template. 187 | * Good to use once the final template part has been requested. 188 | */ 189 | protected function unsetTemplateData(): void 190 | { 191 | global $wp_query; 192 | 193 | foreach (\array_unique($this->template_data_var_names) as $var) { 194 | unset($wp_query->query_vars[$var]); 195 | } 196 | } 197 | 198 | /** 199 | * Retrieve the name of the highest priority template file that exists. 200 | * 201 | * @param array $template_names Template file(s) to search for, in order. 202 | * 203 | * @return string 204 | * @throws \Exception 205 | * @SuppressWarnings(PHPMD.MissingImport) 206 | */ 207 | private function getLocatedFile(array $template_names): string 208 | { 209 | $cache_key = $this->getCacheKey($template_names); 210 | 211 | // If the key is in the cache array, we've already located this file. 212 | if ($cache_key && !empty($this->template_path_cache[$cache_key])) { 213 | return $this->template_path_cache[$cache_key]; 214 | } 215 | // No file found yet. 216 | $located = false; 217 | // Remove empty entries. 218 | $template_names = \array_filter($template_names); 219 | $template_paths = $this->getTemplatePaths(); 220 | 221 | // Try to find a template file. 222 | foreach ($template_names as $template_name) { 223 | // Trim off any slashes from the template name. 224 | $template_name = \ltrim($template_name, '/'); 225 | 226 | // Try locating this template file by looping through the template paths. 227 | foreach ($template_paths as $template_path) { 228 | if (\file_exists($template_path . $template_name)) { 229 | $located = $template_path . $template_name; 230 | $this->template_path_cache[$cache_key] = $located; 231 | break 2; 232 | } 233 | } 234 | } 235 | 236 | if (!is_string($located)) { 237 | throw new \Exception('Template not found'); 238 | } 239 | 240 | return $located; 241 | } 242 | 243 | /** 244 | * Given a slug and optional name, create the file names of templates. 245 | * 246 | * @param string $slug Template slug. 247 | * @param string|null $name Template variation name. 248 | * 249 | * @return array 250 | */ 251 | private function getTemplateFileNames(string $slug, ?string $name): array 252 | { 253 | $templates = []; 254 | if (\is_string($name)) { 255 | $templates[] = $slug . '-' . $name . '.php'; 256 | } 257 | $templates[] = $slug . '.php'; 258 | 259 | /** 260 | * Allow template choices to be filtered. 261 | * 262 | * The resulting array should be in the order of most specific first, to least specific last. 263 | * e.g. 0 => recipe-instructions.php, 1 => recipe.php 264 | * 265 | * @param array $templates Names of template files that should be looked for, for given slug and name. 266 | * @param string $slug Template slug. 267 | * @param string $name Template variation name. 268 | */ 269 | return \apply_filters(Plugin::TAG . 'template_loader/get_template_part', $templates, $slug, $name); 270 | } 271 | 272 | /** 273 | * Use the template names as a cache key. 274 | * 275 | * @param array $names 276 | * @return string|null 277 | */ 278 | private function getCacheKey(array $names): ?string 279 | { 280 | $values = \array_values($names); 281 | 282 | return \array_shift($values); 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/Plugin/TemplateLoaderInterface.php: -------------------------------------------------------------------------------- 1 | names === [] || $this->args === [] || $this->labels === []) { 53 | throw new Exception('Required class properties not set.'); 54 | } 55 | } 56 | 57 | /** 58 | * Register the Post Type. 59 | * @link https://posttypes.jjgrainger.co.uk/post-types/create-a-post-type 60 | * @return PostType 61 | */ 62 | protected function registerPostType(): PostType 63 | { 64 | $post_type = new PostType($this->getNames(), $this->getArgs(), $this->getLabels()); 65 | $post_type->register(); 66 | 67 | return $post_type; 68 | } 69 | 70 | /** 71 | * Gets the Post Type name(s). 72 | * @return array 73 | */ 74 | protected function getNames(): array 75 | { 76 | return $this->names; 77 | } 78 | 79 | /** 80 | * Set's the Post Type name(s). 81 | * @param array $names 82 | */ 83 | protected function setNames(array $names): void 84 | { 85 | $this->names = $this->setDefaultNames($names); 86 | } 87 | 88 | /** 89 | * Gets the Post Type args. 90 | * @return array 91 | */ 92 | protected function getArgs(): array 93 | { 94 | return $this->args; 95 | } 96 | 97 | /** 98 | * Sets the Post Type args. 99 | * @param array $args 100 | */ 101 | protected function setArgs(array $args): void 102 | { 103 | $this->args = $this->setDefaultArgs($args); 104 | } 105 | 106 | /** 107 | * Gets the Post Type labels. 108 | * @return array 109 | */ 110 | protected function getLabels(): array 111 | { 112 | return $this->labels; 113 | } 114 | 115 | /** 116 | * Sets the Post Type labels. 117 | * @param array $labels 118 | */ 119 | protected function setLabels(array $labels): void 120 | { 121 | $this->labels = $labels; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/PostTypes/Columns/Api/ColumnsRegistrar.php: -------------------------------------------------------------------------------- 1 | addFilter(self::TAG_COLUMNS_MANAGER_REGISTRAR, [$this, 'registerColumns']); 27 | parent::addHooks(); // Initiate the parent hooks. 28 | } 29 | 30 | /** 31 | * Get all registered fields for post_types from our filter. 32 | * @return array 33 | */ 34 | public function getObjectClasses(): array 35 | { 36 | return array_filter( 37 | apply_filters(self::TAG_COLUMNS_MANAGER_REGISTRAR, []), 38 | fn(string $column): bool => !empty($column), 39 | ARRAY_FILTER_USE_KEY 40 | ); 41 | } 42 | 43 | /** 44 | * Return the array of columns to register. 45 | * @param array $columns 46 | * @return array 47 | */ 48 | abstract protected function registerColumns(array $columns = []): array; 49 | } 50 | -------------------------------------------------------------------------------- /src/PostTypes/Columns/Api/ColumnsTrait.php: -------------------------------------------------------------------------------- 1 | %3$s | 24 | 25 | %6$s', 26 | \esc_url(\get_edit_post_link($post_id)), 27 | \sprintf( 28 | \esc_attr__( 29 | 'Edit the “%1$s” %2$s', 30 | 'wp-utilities' 31 | ), 32 | \esc_attr(\get_the_title($post_id)), 33 | \esc_attr(\get_post_type_object(\get_post_type($post_id))->labels->singular_name) 34 | ), 35 | \esc_html__('Edit', 'wp-utilities'), 36 | \esc_attr($meta_key), 37 | \esc_attr($post_id), 38 | \esc_html__('Filter', 'wp-utilities'), 39 | \sprintf( 40 | \esc_attr_x( 41 | 'Filter %1$s by the “%2$s” %3$s', 42 | 'Title attribute to filter by a program.', 43 | 'wp-utilities' 44 | ), 45 | \esc_attr(\get_post_type_object($post_type)->labels->name ?? ''), 46 | \esc_attr(\get_the_title($post_id)), 47 | \esc_attr(\get_post_type_object(\get_post_type($post_id))->labels->singular_name) 48 | ) 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/PostTypes/CustomFields/Api/CurrentPostTrait.php: -------------------------------------------------------------------------------- 1 | getRequest()->query; 38 | $request = $this->getRequest()->request; 39 | $post_id = $query->has('post') ? 40 | $query->get('post') : ($request->has('post_ID') ? $request->get('post_ID') : null); 41 | 42 | return is_numeric($post_id) ? absint($post_id) : null; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/PostTypes/CustomFields/Api/CustomFieldsRegistrar.php: -------------------------------------------------------------------------------- 1 | addFilter(self::TAG_CUSTOM_FIELDS_MANAGER_REGISTRAR, [$this, 'registerCustomFields']); 27 | parent::addHooks(); // Initiate the parent hooks. 28 | } 29 | 30 | /** 31 | * Get all registered fields for post_types from our filter. 32 | * @return array 33 | */ 34 | public function getObjectClasses(): array 35 | { 36 | return array_filter( 37 | apply_filters(self::TAG_CUSTOM_FIELDS_MANAGER_REGISTRAR, []), 38 | fn(string $custom_field): bool => !empty($custom_field), 39 | ARRAY_FILTER_USE_KEY 40 | ); 41 | } 42 | 43 | /** 44 | * Return the array of custom_fields to register. 45 | * @param array $custom_fields 46 | * @return array 47 | */ 48 | abstract protected function registerCustomFields(array $custom_fields = []): array; 49 | } 50 | -------------------------------------------------------------------------------- /src/PostTypes/CustomFields/Api/CustomFieldsRegistrarInterface.php: -------------------------------------------------------------------------------- 1 | static::POST_TYPE, 23 | 'slug' => static::SLUG, 24 | ]); 25 | } 26 | 27 | /** 28 | * Set default post type args. 29 | * @param array $args 30 | * @return array 31 | */ 32 | protected function setDefaultArgs(array $args): array 33 | { 34 | return \wp_parse_args($args, [ 35 | 'public' => true, 36 | 'exclude_from_search' => false, 37 | 'publicly_queryable' => true, 38 | 'show_ui' => true, 39 | 'show_in_nav_menus' => false, 40 | 'show_in_admin_bar' => false, 41 | 'show_in_rest' => true, 42 | 'rest_base' => $this->getUrlSlug(), 43 | 'capability_type' => 'post', 44 | 'hierarchical' => false, 45 | 'has_archive' => true, 46 | 'query_var' => true, 47 | 'can_export' => true, 48 | 'rewrite_no_front' => false, 49 | 'supports' => ['title'], 50 | 'rewrite' => [ 51 | 'slug' => $this->getUrlSlug(), 52 | ], 53 | 'delete_with_user' => false, 54 | ]); 55 | } 56 | 57 | /** 58 | * Build a data image svg+xml encoded for background image usage in place of dashicons for 59 | * the `menu_icon` field. 60 | * Visit the GitHub URL: https://github.com/FortAwesome/Font-Awesome/tree/master/svgs/solid and grab the raw 61 | * SVG HTML to pass into this method. 62 | * @link https://stackoverflow.com/a/42265057 63 | * @return string 64 | */ 65 | protected function buildBase64DataImage(): string 66 | { 67 | return \sprintf( 68 | 'data:image/svg+xml;base64, %s', 69 | \base64_encode( 70 | \str_replace( 71 | ['getSvg() 74 | ) 75 | ) 76 | ); 77 | } 78 | 79 | /** 80 | * Return the raw SVG HTML. 81 | * Overwrite this in the inherited class. 82 | * @return string 83 | */ 84 | protected function getSvg(): string 85 | { 86 | return ''; 87 | } 88 | 89 | /** 90 | * Get the post types URL slug. Convert post type underscores and dashed to camelCase. 91 | * @return string 92 | */ 93 | private function getUrlSlug(): string 94 | { 95 | if (static::URL_SLUG !== null) { 96 | return static::URL_SLUG; 97 | } 98 | 99 | return \str_replace(['_', '-'], '', \lcfirst(\ucwords(static::SLUG, '_-'))); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/PostTypes/PostTypesRegistrar.php: -------------------------------------------------------------------------------- 1 | addFilter(self::TAG_POST_TYPE_MANAGER_REGISTRAR, [$this, 'registerPostTypes']); 27 | parent::addHooks(); // Initiate the parent hooks. 28 | } 29 | 30 | /** 31 | * Get all registered post_types from our filter. 32 | * @return array 33 | */ 34 | public function getObjectClasses(): array 35 | { 36 | return array_filter( 37 | apply_filters(self::TAG_POST_TYPE_MANAGER_REGISTRAR, []), 38 | fn(string $post_type): bool => !empty($post_type), 39 | ARRAY_FILTER_USE_KEY 40 | ); 41 | } 42 | 43 | /** 44 | * Return the array of post_types to register. 45 | * @param array $post_types 46 | * @return array 47 | */ 48 | abstract protected function registerPostTypes(array $post_types = []): array; 49 | } 50 | -------------------------------------------------------------------------------- /src/ReflectionTrait.php: -------------------------------------------------------------------------------- 1 | normalizeDate($date); 31 | $response->header(RestRequest::HEADER_SUNSET, $sunset); 32 | if ($sunset > current_datetime()) { 33 | $response->header(RestRequest::HEADER_DEPRECATION, 'true'); 34 | } 35 | 36 | if ($link !== null) { 37 | $response->header('Link', sprintf('<%s>; rel="sunset"', $link), false); 38 | } 39 | } 40 | 41 | /** 42 | * Format the string date into the `RFC7231` format. 43 | * @param string $date 44 | * @return string 45 | */ 46 | private function normalizeDate(string $date): string 47 | { 48 | return (new DateTimeImmutable($date, wp_timezone()))->format(DateTimeInterface::RFC7231); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/RestApi/Http/RegisterGetRoute.php: -------------------------------------------------------------------------------- 1 | registerRestRoute($namespace, $route, $callback, \WP_REST_Server::READABLE, $args); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/RestApi/Http/RegisterPostRoute.php: -------------------------------------------------------------------------------- 1 | registerRestRoute($namespace, $route, $callback, \WP_REST_Server::CREATABLE, $args); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/RestApi/Http/RestRouteInterface.php: -------------------------------------------------------------------------------- 1 | addAction('rest_api_init', [$this, 'initializeRoute']); 27 | } 28 | 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | public function registerRestRoute( 33 | string $namespace, 34 | string $route, 35 | callable $callback, 36 | string $method, 37 | array $args = [] 38 | ): bool { 39 | $defaults = [ 40 | self::ARG_METHODS => $method, 41 | self::ARG_CALLBACK => $callback, 42 | self::ARG_PERMISSION_CALLBACK => '__return_true', 43 | ]; 44 | $args = \wp_parse_args($args, $defaults); 45 | 46 | return \register_rest_route($namespace, $route, $args); 47 | } 48 | 49 | /** 50 | * {@inheritdoc} 51 | */ 52 | abstract public function initializeRoute(\WP_REST_Server $server): void; 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | abstract public function registerRoute( 58 | string $namespace, 59 | string $route, 60 | callable $callback, 61 | array $args = [] 62 | ): void; 63 | } 64 | -------------------------------------------------------------------------------- /src/RestApi/PostTypeFilter.php: -------------------------------------------------------------------------------- 1 | post_types = \get_post_types(['show_in_rest' => true], 'names'); 41 | } 42 | 43 | /** 44 | * Add class hooks. 45 | */ 46 | public function addHooks(): void 47 | { 48 | $this->addAction('rest_api_init', [$this, 'addRestFilters']); 49 | } 50 | 51 | /** 52 | * Allow the `filter[]` to REST responses for our post types. 53 | * Taken from https://github.com/WP-API/rest-filter 54 | */ 55 | protected function addRestFilters(): void 56 | { 57 | $post_types = \array_filter( 58 | \apply_filters(\sprintf('%s/filter_post_types', Plugin::TAG), $this->post_types) 59 | ); 60 | \array_walk($post_types, function (string $slug): void { 61 | $post_type = \get_post_type_object($slug); 62 | if ($post_type instanceof \WP_Post_Type) { 63 | $this->addFilter("rest_{$post_type->name}_query", [$this, 'addFilterParam'], 10, 2); 64 | } 65 | }); 66 | } 67 | 68 | /** 69 | * Add the `filter` parameter to the REST call query which is then passed to WP_Query. 70 | * 71 | * @see https://developer.wordpress.org/reference/classes/wp_query/ For available params to pass to the filter. 72 | * 73 | * @param array $args The query arguments. 74 | * @param \WP_REST_Request $request Full details about the request. 75 | * 76 | * @return array $args. 77 | */ 78 | protected function addFilterParam(array $args, \WP_REST_Request $request): array 79 | { 80 | // Bail out if no filter parameter is set. 81 | if (empty($request->get_params()) || empty($request->get_params()[self::QUERY_PARAM])) { 82 | return $args; 83 | } 84 | $filter = $request->get_params()[self::QUERY_PARAM]; 85 | if ( 86 | isset($filter['posts_per_page']) && 87 | ((int)$filter['posts_per_page'] >= 1 && (int)$filter['posts_per_page'] <= 100) 88 | ) { 89 | $args['posts_per_page'] = $filter['posts_per_page']; 90 | } 91 | $vars = $this->getPublicQueryVars($request); 92 | foreach ($vars as $var) { 93 | if (isset($filter[$var])) { 94 | $args[$var] = $filter[$var]; 95 | } 96 | } 97 | 98 | return $args; 99 | } 100 | 101 | /** 102 | * Get WordPress' global public query vars, and merge them with our custom allowed vars. 103 | * 104 | * @param \WP_REST_Request|null $request Pass in the current WP_REST_Request object to the filter. 105 | * @return array 106 | */ 107 | private function getPublicQueryVars(?\WP_REST_Request $request = null): array 108 | { 109 | $vars = \apply_filters('rest_query_vars', $GLOBALS['wp']->public_query_vars, $request); 110 | 111 | return \array_unique(\array_merge($vars, self::QUERY_VARS)); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Taxonomies/AbstractTaxonomy.php: -------------------------------------------------------------------------------- 1 | names === [] || $this->args === [] || $this->labels === []) { 57 | throw new Exception('Required class properties not set.'); 58 | } 59 | } 60 | 61 | /** 62 | * Get all `TERM_` prefixed constants in an array key/value pair. 63 | * @param object $argument 64 | * @return array 65 | */ 66 | public static function getTerms(object $argument): array 67 | { 68 | return array_filter((new ReflectionClass($argument))->getConstants(), static function (string $key): bool { 69 | return str_contains($key, 'TERM_'); 70 | }, ARRAY_FILTER_USE_KEY); 71 | } 72 | 73 | /** 74 | * Register the Taxonomy. 75 | * @link https://posttypes.jjgrainger.co.uk/taxonomies 76 | * @return Taxonomy 77 | */ 78 | protected function registerTaxonomy(): Taxonomy 79 | { 80 | $taxonomy = new Taxonomy($this->getNames(), $this->getArgs(), $this->getLabels()); 81 | $this->registerPostTypes($taxonomy); 82 | $taxonomy->register(); 83 | 84 | return $taxonomy; 85 | } 86 | 87 | /** 88 | * Gets the Taxonomy name(s). 89 | * @return array 90 | */ 91 | protected function getNames(): array 92 | { 93 | return $this->names; 94 | } 95 | 96 | /** 97 | * Sets the Taxonomy name(s). 98 | * @param array $names 99 | */ 100 | protected function setNames(array $names): void 101 | { 102 | $this->names = $this->setDefaultNames($names); 103 | } 104 | 105 | /** 106 | * Gets the Taxonomy args. 107 | * @return array 108 | */ 109 | protected function getArgs(): array 110 | { 111 | return $this->args; 112 | } 113 | 114 | /** 115 | * Sets the Taxonomy args. 116 | * @param array $args 117 | */ 118 | protected function setArgs(array $args): void 119 | { 120 | $this->args = $this->setDefaultArgs($args); 121 | } 122 | 123 | /** 124 | * Ges the Taxonomy labels. 125 | * @return array 126 | */ 127 | protected function getLabels(): array 128 | { 129 | return $this->labels; 130 | } 131 | 132 | /** 133 | * Set's the Taxonomy labels. 134 | * @param array $labels 135 | */ 136 | protected function setLabels(array $labels): void 137 | { 138 | $this->labels = $labels; 139 | } 140 | 141 | /** 142 | * Register the taxonomy to a post type. 143 | * @param Taxonomy $taxonomy Taxonomy object. 144 | */ 145 | private function registerPostTypes(Taxonomy $taxonomy): void 146 | { 147 | $post_types = static::POST_TYPE; 148 | if (!\is_array($post_types)) { 149 | $post_types = [$post_types]; 150 | } 151 | 152 | $taxonomy->posttype($post_types); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Taxonomies/TaxonomyRegistrar.php: -------------------------------------------------------------------------------- 1 | addFilter(self::TAG_TAXONOMY_MANAGER_REGISTRAR, [$this, 'registerTaxonomies']); 25 | parent::addHooks(); // Initiate the parent hooks. 26 | } 27 | 28 | /** 29 | * Get all registered taxonomies from our filter. 30 | * @return string[] 31 | */ 32 | public function getObjectClasses(): array 33 | { 34 | return array_filter( 35 | apply_filters(self::TAG_TAXONOMY_MANAGER_REGISTRAR, []), 36 | fn(string $taxonomy): bool => !empty($taxonomy), 37 | ARRAY_FILTER_USE_KEY 38 | ); 39 | } 40 | 41 | /** 42 | * Return the array of taxonomies to register. 43 | * @param array $taxonomies 44 | * @return array 45 | */ 46 | abstract protected function registerTaxonomies(array $taxonomies = []): array; 47 | } 48 | -------------------------------------------------------------------------------- /src/Taxonomies/TaxonomyTrait.php: -------------------------------------------------------------------------------- 1 | static::TAXONOMY_TYPE, 26 | 'slug' => static::SLUG, 27 | ]); 28 | } 29 | 30 | /** 31 | * Set default taxonomy args. 32 | * @param array $args 33 | * @return array 34 | */ 35 | protected function setDefaultArgs(array $args): array 36 | { 37 | return wp_parse_args($args, [ 38 | 'public' => true, 39 | 'publicly_queryable' => true, 40 | 'show_ui' => true, 41 | 'show_in_menu' => true, 42 | 'show_in_nav_menus' => false, 43 | 'show_in_rest' => true, 44 | 'rest_base' => $this->getUrlSlug(), 45 | 'show_in_quick_edit' => false, 46 | 'meta_box_cb' => false, 47 | 'show_admin_column' => true, 48 | 'hierarchical' => false, 49 | 'query_var' => true, 50 | 'rewrite' => [ 51 | 'slug' => $this->getUrlSlug(), 52 | ], 53 | ]); 54 | } 55 | 56 | /** 57 | * Get the taxonomy URL slug. Convert taxonomy underscores and dashed to camelCase. 58 | * @return string 59 | */ 60 | private function getUrlSlug(): string 61 | { 62 | if (static::URL_SLUG !== null) { 63 | return static::URL_SLUG; 64 | } 65 | 66 | return str_replace(['_', '-'], '', lcfirst(ucwords(static::SLUG, '_-'))); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Utils/AbstractSingleton.php: -------------------------------------------------------------------------------- 1 | setDefaultPaths(); 49 | } 50 | 51 | /** 52 | * Return a view file. 53 | * @param string $view The view file to render from the `views` directory. 54 | * @return string|null 55 | */ 56 | public function get(string $view): ?string 57 | { 58 | $file = $this->sanitizeFileExtension($view . '.php'); 59 | 60 | return $this->getViewPath($file); 61 | } 62 | 63 | /** 64 | * Render a view. 65 | * @param string $filename The view file to render from the `views` directory. 66 | * @param array $viewData 67 | */ 68 | public function render(string $filename, array $viewData = []): void 69 | { 70 | /* 71 | * Clear view data, so we can use the same object multiple times, 72 | * otherwise the view data will persist and may cause problems. 73 | */ 74 | $this->viewData = []; 75 | $this->load([$filename, $viewData]); 76 | } 77 | 78 | /** 79 | * Return a view. 80 | * @param string $filename The view file to render from the `views` directory. 81 | * @param array $viewData 82 | * @return string 83 | */ 84 | public function retrieve(string $filename, array $viewData = []): string 85 | { 86 | ob_start(); 87 | $this->render($filename, $viewData); 88 | 89 | return ob_get_clean(); 90 | } 91 | 92 | /** 93 | * Set variables to be available in any view 94 | * @param object|array $vars 95 | */ 96 | public function setVars(object|array $vars = []): void 97 | { 98 | if (is_object($vars)) { 99 | $vars = get_object_vars($vars); 100 | } 101 | 102 | if (is_array($vars) && count($vars) > 0) { 103 | foreach ($vars as $key => $val) { 104 | $this->viewData[$key] = $val; 105 | } 106 | } 107 | } 108 | 109 | /** 110 | * Add View Path. Prepend the paths array with the new path 111 | * @param string $path 112 | */ 113 | public function addPath(string $path): void 114 | { 115 | array_unshift($this->viewPaths, trailingslashit(realpath($path))); 116 | } 117 | 118 | /** 119 | * Get view data. 120 | * @return array 121 | */ 122 | public function getViewData(): array 123 | { 124 | return $this->viewData; 125 | } 126 | 127 | /** 128 | * Internal view loader 129 | * @param array $args 130 | * @throws RuntimeException 131 | */ 132 | private function load(array $args): void 133 | { 134 | $this->setDefaultPaths(); 135 | [$view, $data] = $args; 136 | 137 | // Add a file extension the view 138 | $file = $this->sanitizeFileExtension($view . '.php'); 139 | 140 | // Get the view path 141 | $viewPath = $this->getViewPath($file); 142 | 143 | // Display error if view not found 144 | if ($viewPath === null) { 145 | $this->viewNotFoundError($file); 146 | } 147 | 148 | if (is_array($data) && !empty($data)) { 149 | $this->viewData = array_merge($this->viewData, $data); 150 | } 151 | 152 | extract($this->viewData); 153 | include $viewPath; 154 | } 155 | 156 | /** 157 | * Set the default paths. 158 | * The default view directories always need to be loaded first 159 | */ 160 | private function setDefaultPaths(): void 161 | { 162 | if (empty($this->viewPaths)) { 163 | $this->viewPaths = [sprintf('%1$s/views/', dirname(__DIR__, 2))]; 164 | } 165 | } 166 | 167 | /** 168 | * Sanitize the file extension. 169 | * @param string $file 170 | * @return string 171 | */ 172 | private function sanitizeFileExtension(string $file): string 173 | { 174 | return str_replace('.php.php', '.php', $file); 175 | } 176 | 177 | /** 178 | * Get the view path. 179 | * @param string $file 180 | * @return string|null 181 | */ 182 | private function getViewPath(string $file): ?string 183 | { 184 | $file = $this->sanitizeFileExtension($file); 185 | foreach ($this->viewPaths as $viewDir) { 186 | if (file_exists($viewDir . $file)) { 187 | return $viewDir . $file; 188 | } 189 | } 190 | 191 | return null; 192 | } 193 | 194 | /** 195 | * Display error when no view found. 196 | * @param string $file 197 | * @throws RuntimeException 198 | */ 199 | private function viewNotFoundError(string $file): void 200 | { 201 | $errText = PHP_EOL . 202 | 'View file "' . $file . '" not found.' . PHP_EOL . 203 | 'Directories checked: ' . PHP_EOL . 204 | '[' . implode('],' . PHP_EOL . '[', $this->viewPaths) . ']' . PHP_EOL; 205 | 206 | throw new RuntimeException($errText); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/Utils/Viewable.php: -------------------------------------------------------------------------------- 1 | getContainer() instanceof ContainerInterface) { 33 | throw new RuntimeException( 34 | sprintf('%s must use %s', get_class($this), ContainerAwareTrait::class) 35 | ); 36 | } 37 | 38 | if (!$this->view instanceof View) { 39 | try { 40 | $this->view = $this->getContainer()->get($id); 41 | } catch (Throwable) { 42 | return null; 43 | } 44 | } 45 | 46 | return $this->view; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/WpAdmin/AddPluginIcons.php: -------------------------------------------------------------------------------- 1 | ']/icon.svg', 17 | * '1x' => ']/icon-128x128.png|jpg', 18 | * '2x' => ']/icon-256x256.png|jpg' 19 | * ] 20 | * @var array $icons 21 | */ 22 | private array $icons; 23 | 24 | /** 25 | * AddPluginIcon constructor. 26 | * @param array $icons 27 | */ 28 | public function __construct(array $icons) 29 | { 30 | $this->icons = $icons; 31 | } 32 | 33 | /** 34 | * Add class hooks 35 | */ 36 | public function addHooks(): void 37 | { 38 | if (!empty($this->icons)) { 39 | $this->addFilter('all_plugins', [$this, 'filterAllPlugins']); 40 | } 41 | } 42 | 43 | /** 44 | * Disable plugin update checks for the current plugin 45 | * @link https://gist.github.com/robincornett/1fe6045b1acc64a329460e5c6023853e 46 | * @param array $plugins 47 | * @return array 48 | */ 49 | protected function filterAllPlugins(array $plugins): array 50 | { 51 | if (\array_key_exists($this->getPlugin()->getBasename(), $plugins)) { 52 | $icons = ['svg', '1x', '2x']; 53 | foreach ($icons as $key) { 54 | if (!\array_key_exists($key, $this->icons)) { 55 | continue; 56 | } 57 | $plugins[$this->getPlugin()->getBasename()]['icons'][$key] = $this->icons[$key]; 58 | } 59 | } 60 | 61 | return $plugins; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/WpAdmin/AdminColumns.php: -------------------------------------------------------------------------------- 1 | addFilter('manage_' . $post_type . '_posts_columns', [$this, 'postsColumns'], 99); 46 | $this->addAction('manage_' . $post_type . '_posts_custom_column', [$this, 'postsCustomColumn'], 10, 2); 47 | $this->addFilter('manage_edit-' . $post_type . '_sortable_columns', [$this, 'sortableColumns']); 48 | } 49 | 50 | /** 51 | * Manage all Post Type columns, by adding our custom filter allowing new columns. 52 | * @param array $columns 53 | * @return array 54 | */ 55 | protected function postsColumns(array $columns): array 56 | { 57 | $post_type = str_replace(['manage_', '_posts_columns'], '', current_filter()); 58 | if (isset($columns['date'])) { 59 | $date = $columns['date']; 60 | unset($columns['date']); 61 | } 62 | $columns = apply_filters(self::TAG_MANAGE_POSTS_COLUMNS, $columns, $post_type); 63 | if (isset($date)) { 64 | $columns['date'] = $date; 65 | } 66 | 67 | return $columns; 68 | } 69 | 70 | /** 71 | * Manage all Post Type columns, by adding our custom action allowing manipulation of the column data. 72 | * @param string $column 73 | * @param int $post_id 74 | */ 75 | protected function postsCustomColumn(string $column, int $post_id): void 76 | { 77 | $post_type = str_replace(['manage_', '_posts_custom_column'], '', current_filter()); 78 | do_action(self::TAG_MANAGE_POSTS_CUSTOM_COLUMN, $column, $post_id, $post_type); 79 | } 80 | 81 | /** 82 | * Allow filtering of which columns are "sortable". 83 | * @param array $columns 84 | * @return array 85 | */ 86 | protected function sortableColumns(array $columns): array 87 | { 88 | $post_type = str_replace(['manage_edit-', '_sortable_columns'], '', current_filter()); 89 | $filter = apply_filters(self::TAG_MANAGE_MANAGE_EDIT_SORTABLE_COLUMNS, $columns, $post_type); 90 | 91 | return wp_parse_args($columns, $filter); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/WpAdmin/Capabilities.php: -------------------------------------------------------------------------------- 1 | addFilter('map_meta_cap', [$this, 'mapDoNotAllowCap'], 99, 3); 32 | } 33 | 34 | /** 35 | * Does the user have a "role". 36 | * @param int $user_id User ID. 37 | * @param string $role User ID. 38 | * @return bool 39 | */ 40 | public static function userHasRole(int $user_id, string $role): bool 41 | { 42 | $user = get_user_by('ID', $user_id); 43 | $roles = $user instanceof WP_User ? $user->roles : []; 44 | 45 | return array_key_exists($role, array_flip($roles)); 46 | } 47 | 48 | /** 49 | * Does the user have the correct "role". 50 | * Note we've arbitrarily created a "Super Admin" role from the Users > Roles page. 51 | * @param int $user_id User ID. 52 | * @return bool 53 | */ 54 | public static function userHasSuperAdminRole(int $user_id): bool 55 | { 56 | return self::userHasRole($user_id, RoleManager::ROLE_SUPER_ADMIN); 57 | } 58 | 59 | /** 60 | * Filters a user's capabilities. In this case we're disabling all users caps that match those in the switch 61 | * case below. 62 | * @param array $caps Returns the user's actual capabilities. 63 | * @param string|null $cap Capability name. 64 | * @param int $user_id The user ID. 65 | * @return array 66 | */ 67 | protected function mapDoNotAllowCap(array $caps, ?string $cap, int $user_id): array 68 | { 69 | if (self::userHasSuperAdminRole($user_id)) { 70 | return $caps; 71 | } 72 | 73 | switch ($cap) { 74 | case 'edit_files': 75 | case 'edit_plugins': 76 | case 'edit_themes': 77 | case 'update_plugins': 78 | case 'delete_plugins': 79 | case 'install_plugins': 80 | case 'upload_plugins': 81 | case 'update_themes': 82 | case 'delete_themes': 83 | case 'install_themes': 84 | case 'upload_themes': 85 | case 'update_core': 86 | case 'delete_users': 87 | $caps[] = self::DO_NOT_ALLOW; 88 | } 89 | 90 | return $caps; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/WpAdmin/Columns/AbstractColumns.php: -------------------------------------------------------------------------------- 1 | addAction($this->getColumnContentAction($post_type), [$this, 'addPostTypeColumnContent'], 10, 2); 79 | $this->addFilter($this->getColumnFilter($post_type), [$this, 'addPostTypeColumns']); 80 | $this->addAction( 81 | sprintf(self::HOOK_WP_QUERY_GET_ALL_IDS, $post_type), 82 | [$this, 'setWpQueryGetAllIdsCache'], 83 | 10, 84 | 4 85 | ); 86 | } 87 | 88 | /** 89 | * Set the option(s) key values by reference to the array. 90 | * @param array $data The array of data (usually meta values) 91 | * @param string $key The Meta Key 92 | * @param array $options The passed options array for the collection of fields 93 | */ 94 | protected function setOptionKeyValues(array $data, string $key, array &$options = []): void 95 | { 96 | array_walk($data, static function (mixed $value, int $key) use (&$data): void { 97 | $data[$key] = is_array($value) ? array_shift($value) : $value; 98 | }); 99 | foreach (array_filter(array_unique($data)) as $item) { 100 | if (is_numeric($item) && get_post($item)) { 101 | $title = get_post($item)->post_title; 102 | } 103 | $options[$key][] = new OptionValueLabel(strval($item), esc_html($title ?? $item)); 104 | } 105 | } 106 | 107 | /** 108 | * Gets all cached posts by post type (deferred by a single background CRON task). 109 | * @param string $post_type The Post Type. 110 | * @param array|null $args Optional (different) query args. 111 | * @param int $expiration Optional expiration cache time. Defaults to `WEEK_IN_SECONDS`. 112 | * @return array 113 | */ 114 | protected function getCachedPostsDeferred( 115 | string $post_type, 116 | ?array $args = null, 117 | int $expiration = \WEEK_IN_SECONDS 118 | ): array { 119 | $args ??= []; 120 | $key = $this->getHashedKey(sprintf('%s-%s', __METHOD__, $post_type)); 121 | $post_ids = $this->getCache($key, self::class); 122 | if (is_array($post_ids) && !empty($post_ids)) { 123 | return $post_ids; 124 | } 125 | // If we don't have the cache, trigger a single cron event to populate it in the background. 126 | $hook = sprintf(self::HOOK_WP_QUERY_GET_ALL_IDS, $post_type); 127 | if (!wp_next_scheduled($hook, [$key, $post_type, $args, $expiration])) { 128 | wp_schedule_single_event(time(), $hook, [$key, $post_type, $args, $expiration]); 129 | } 130 | 131 | return []; 132 | } 133 | 134 | /** 135 | * Single CRON event action, to populate the CACHE for this large query. 136 | * @param string $key The hashed cache key. 137 | * @param string $post_type The Post Type. 138 | * @param array $args Incoming query args array. 139 | * @param int $expiration Expiration cache time. 140 | */ 141 | protected function setWpQueryGetAllIdsCache(string $key, string $post_type, array $args, int $expiration): void 142 | { 143 | $post_ids = $this->wpQueryGetAllIds($post_type, $args); 144 | $this->setCache($key, $post_ids, self::class, $expiration); 145 | } 146 | 147 | /** 148 | * Does the current GET request have $field_name assigned as the admin filter query? 149 | * @param string $field_name 150 | * @param Request|null $request 151 | * @return bool 152 | * @throws \BadMethodCallException 153 | */ 154 | protected function requestHas(string $field_name, ?Request $request = null): bool 155 | { 156 | if ($request === null && (!\method_exists($this, 'getRequest') || $this->getRequest() === null)) { 157 | throw new \BadMethodCallException( 158 | sprintf( 159 | 'Class missing `%s` implementation or passing `%s` as a param.', 160 | HttpFoundationRequestInterface::class, 161 | Request::class 162 | ) 163 | ); 164 | } 165 | $request ??= $this->getRequest(); 166 | 167 | return ($request->query->has('s') || 168 | $request->query->has(RestrictPostsInterface::ADMIN_SEARCH_FIELD_VALUE)) && 169 | $request->query->has(RestrictPostsInterface::ADMIN_FILTER_FIELD_NAME) && 170 | $request->query->get(RestrictPostsInterface::ADMIN_FILTER_FIELD_NAME) === $field_name; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/WpAdmin/Dashboard/SimplePie.php: -------------------------------------------------------------------------------- 1 | get_items(0, $pie->get_item_quantity($max)) : []; 61 | }; 62 | 63 | $pie_items = $get_items($pie); 64 | // If the feed was erroneous 65 | if (!$pie_items) { 66 | $key = $this->getHashedKey($url); 67 | delete_transient('feed_' . $key); 68 | delete_transient('feed_mod_' . $key); 69 | $pie = $get_pie($url); 70 | $pie_items = $get_items($pie); 71 | } 72 | 73 | return is_array($pie_items) ? $pie_items : []; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/WpAdmin/Dashboard/Widget.php: -------------------------------------------------------------------------------- 1 | feed_url = $url; 43 | } 44 | 45 | /** 46 | * Get the feed URL. 47 | * @return string 48 | */ 49 | public function getFeedUrl(): string 50 | { 51 | return $this->feed_url; 52 | } 53 | 54 | /** 55 | * Set the widget ID. 56 | * @param string $widget_id 57 | */ 58 | protected function setWidgetId(string $widget_id): void 59 | { 60 | $this->widget_id = $widget_id; 61 | } 62 | 63 | /** 64 | * Get the request type. 65 | * @return string 66 | */ 67 | public function getType(): string 68 | { 69 | return $this->type; 70 | } 71 | 72 | /** 73 | * Set the request type. 74 | * @param string $type 75 | */ 76 | protected function setType(string $type): void 77 | { 78 | if (!in_array(strtolower($type), [self::TYPE_REST, self::TYPE_RSS], true)) { 79 | return; 80 | } 81 | $this->type = $type; 82 | } 83 | 84 | /** 85 | * Get the widget ID. 86 | * @return string 87 | */ 88 | public function getWidgetId(): string 89 | { 90 | return $this->widget_id; 91 | } 92 | 93 | /** 94 | * Set the widget name. 95 | * @param string $widget_name 96 | */ 97 | protected function setWidgetName(string $widget_name): void 98 | { 99 | $this->widget_name = $widget_name; 100 | } 101 | 102 | /** 103 | * Get the widget name. 104 | * @return string 105 | */ 106 | public function getWidgetName(): string 107 | { 108 | return $this->widget_name; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/WpAdmin/DashboardWidget.php: -------------------------------------------------------------------------------- 1 | args = $args; 43 | } 44 | 45 | /** 46 | * Add class hooks. 47 | */ 48 | public function addHooks(): void 49 | { 50 | $this->addAction('load-index.php', [$this, 'loadIndexPhp']); 51 | } 52 | 53 | /** 54 | * Load additional hooks for this class. 55 | */ 56 | protected function loadIndexPhp(): void 57 | { 58 | $this->setWidget($this->args); 59 | $this->addAction('wp_dashboard_setup', [$this, 'addDashboardWidget']); 60 | } 61 | 62 | /** 63 | * Add Dashboard widget 64 | */ 65 | protected function addDashboardWidget(): void 66 | { 67 | if (!$this->isDashboardAllowed()) { 68 | return; 69 | } 70 | 71 | wp_add_dashboard_widget( 72 | $this->getWidget()->getWidgetId(), 73 | $this->getWidget()->getWidgetName(), 74 | static function (): void { 75 | (new View())->render('dashboard-widget.php'); 76 | } 77 | ); 78 | } 79 | 80 | /** 81 | * Return the current Widget object. 82 | * @return Widget 83 | */ 84 | public function getWidget(): Widget 85 | { 86 | return $this->widget; 87 | } 88 | 89 | /** 90 | * Creates in new instance of the Widget object. 91 | * @param array $args 92 | */ 93 | private function setWidget(array $args): void 94 | { 95 | $this->widget = new Widget($args); 96 | } 97 | 98 | /** 99 | * Check if the dashboard widget is allowed. 100 | * @return bool 101 | */ 102 | private function isDashboardAllowed(): bool 103 | { 104 | $allowed = apply_filters( 105 | sprintf( 106 | self::HOOK_NAME_ALLOWED_S, 107 | sanitize_key($this->getWidget()->getWidgetId()) 108 | ), 109 | true 110 | ); 111 | 112 | return $allowed === true; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/WpAdmin/DisablePluginUpdateCheck.php: -------------------------------------------------------------------------------- 1 | addFilter('http_request_args', [$this, 'httpRequestRemovePluginBasename'], 10, 2); 26 | $this->addFilter('pre_http_request', [$this, 'bypassHttpRequest'], 10, 3); 27 | $this->addFilter('site_transient_update_plugins', [$this, 'transientRemovePluginBasename']); 28 | } 29 | 30 | /** 31 | * Disable plugin update checks for the current plugin 32 | * @link https://stackoverflow.com/a/39217270/558561 33 | * @param array $args An array of HTTP request arguments. 34 | * @param string $url The request URL. 35 | * @return array 36 | */ 37 | protected function httpRequestRemovePluginBasename(array $args, string $url): array 38 | { 39 | if (\str_starts_with($url, self::WP_ORG_UPDATE_CHECK)) { 40 | if (!empty($args['body']['plugins'])) { 41 | $plugins = \json_decode($args['body']['plugins'], true); 42 | unset($plugins['plugins'][$this->getPlugin()->getBasename()]); 43 | $args['body']['plugins'] = \wp_json_encode($plugins); 44 | } 45 | } 46 | if ( 47 | \str_starts_with($url, self::WP_ORG_PLUGINS_INFO) && 48 | \is_string(\parse_url($url, \PHP_URL_QUERY)) 49 | ) { 50 | \parse_str(\parse_url($url, \PHP_URL_QUERY), $result); 51 | if ( 52 | !empty($result['request']) && 53 | !empty($result['request']['slug']) && 54 | $result['request']['slug'] === $this->getPlugin()->getSlug() && 55 | \__return_true() === false // Re-enable when we can validate this works. 56 | ) { 57 | $args[self::BYPASS_KEY] = $this->getPlugin()->getSlug(); 58 | } 59 | } 60 | 61 | return $args; 62 | } 63 | 64 | /** 65 | * Attempt to bypass the HTTP Request if the bypass key is present. 66 | * @todo I was initially investigating certain plugins throwing 404's when they should not be called by the 67 | * info API endpoint, but don't see an easy way to validate this, so I will "disable" the key in the 68 | * `httpRequestRemovePluginBasename` method. 69 | * @param false|array|WP_Error $preempt A preemptive return value of an HTTP request. Default false. 70 | * @param array $parsed_args HTTP request arguments. 71 | * @param string $url The request URL. 72 | * @return mixed 73 | */ 74 | protected function bypassHttpRequest($preempt, array $parsed_args, string $url): mixed 75 | { 76 | if ( 77 | str_starts_with($url, self::WP_ORG_PLUGINS_INFO) && 78 | !empty($parsed_args[self::BYPASS_KEY]) && 79 | $parsed_args[self::BYPASS_KEY] === $this->getPlugin()->getSlug() 80 | ) { 81 | return new WP_Error( 82 | 'bypass_http_request', 83 | \sprintf( 84 | \esc_html__( 85 | 'The plugin `%s` has requested to bypass api.wp.org/plugin/info.', 86 | 'wp-utilities' 87 | ), 88 | $this->getPlugin()->getSlug(), 89 | ), 90 | ['status' => \WP_Http::NOT_FOUND] 91 | ); 92 | } 93 | 94 | return $preempt; 95 | } 96 | 97 | /** 98 | * Remove this plugin from the transient value via the core filter. 99 | * Ignoring those looking for updating via GitHub Updater. 100 | * @link https://gist.github.com/rniswonger/ee1b30e5fd3693bb5f92fbcfabe1654d 101 | * @param mixed $value 102 | * @return mixed 103 | */ 104 | protected function transientRemovePluginBasename(mixed $value): mixed 105 | { 106 | if (isset($value) && \is_object($value) && (!empty($value->response) && \is_array($value->response))) { 107 | if (!$this->hasGitHubUpdater()) { 108 | unset($value->response[$this->getPlugin()->getBasename()]); 109 | } 110 | } 111 | 112 | return $value; 113 | } 114 | 115 | /** 116 | * Does the current plugin use GitHub Updater? 117 | * @link https://github.com/afragen/github-updater 118 | * @return bool 119 | */ 120 | private function hasGitHubUpdater(): bool 121 | { 122 | $key = \sprintf('%s/get_file_data_%s', Plugin::TAG, \sanitize_key($this->getPlugin()->getSlug())); 123 | $data = \wp_cache_get($key, 'wp-utilities'); 124 | if ($data === false) { 125 | $data = \get_file_data($this->getPlugin()->getFile(), ['GitHubPluginURI' => 'GitHub Plugin URI'], 'plugin'); 126 | \wp_cache_set($key, $data, 'wp-utilities', \DAY_IN_SECONDS); 127 | } 128 | 129 | return \is_array($data) && !empty($data['GitHubPluginURI']); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/WpAdmin/FormElementsTrait.php: -------------------------------------------------------------------------------- 1 | ', 39 | esc_attr(sanitize_key($name)), 40 | esc_attr(wp_unslash($this->getRequest()->query->get($name))), 41 | esc_html($placeholder ?? __('Search the value by Meta Key', 'wp-utilities')), 42 | esc_html( 43 | __( 44 | 'Meta Value; search the value by Meta Key. Use "NULL" or "EMPTY" to to search for empty (missing) 45 | value by key. 46 | --- 47 | For advanced search: use `meta_key:"$meta_key" post_title:"$post_title" post_type:"$post_type" to search by the 48 | post title value (ID).', 49 | 'wp-utilities' 50 | ) 51 | ), 52 | esc_attr("meta_key:\"\" post_title:\"\" post_type:\"\"") 53 | ); 54 | } 55 | 56 | /** 57 | * Build a HTML select element. 58 | * @param string $name The ID & query parameter. 59 | * @param string $default_text The Default text option. 60 | * @param array|null $options Array of options via [optgroup label => [[OptionValueLabel object]]]. 61 | */ 62 | protected function selectHtml(string $name, string $default_text, ?array $options = []): void 63 | { 64 | if (empty($options)) { 65 | return; 66 | } 67 | printf(''; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/WpAdmin/Models/OptionValueLabel.php: -------------------------------------------------------------------------------- 1 | value = $value; 35 | $this->label = $label; 36 | } 37 | 38 | /** 39 | * Get the value. 40 | * @return string 41 | */ 42 | public function getValue(): string 43 | { 44 | return $this->value; 45 | } 46 | 47 | /** 48 | * Get the label 49 | * @return string 50 | */ 51 | public function getLabel(): string 52 | { 53 | return $this->label; 54 | } 55 | 56 | /** 57 | * Return an array of value/label keys and their values. 58 | * @return array 59 | */ 60 | public function toArray(): array 61 | { 62 | return [ 63 | self::KEY_VALUE => $this->value, 64 | self::KEY_LABEL => $this->label, 65 | ]; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/WpAdmin/RestrictPostsInterface.php: -------------------------------------------------------------------------------- 1 | 31 | %4$s', 32 | esc_attr(Type::CHECKBOX->value), 33 | esc_attr($field->getName()), 34 | checked(UserMetaFieldRegistrar::getUserMeta($user->ID, $field->getName()), '1', false), 35 | $this->getDescription($field), 36 | ); 37 | } 38 | 39 | /** 40 | * Build an input text. 41 | * @param UserMetaField $field 42 | * @param WP_User $user 43 | * @return string 44 | */ 45 | public function text(UserMetaField $field, WP_User $user): string 46 | { 47 | return sprintf( 48 | '%4$s', 49 | esc_attr(Type::TEXT->value), 50 | esc_attr($field->getName()), 51 | esc_attr(UserMetaFieldRegistrar::getUserMeta($user->ID, $field->getName())), 52 | $this->getDescription($field), 53 | ); 54 | } 55 | 56 | /** 57 | * Build the field description. 58 | * @param UserMetaField $field 59 | * @return string 60 | */ 61 | private function getDescription(UserMetaField $field): string 62 | { 63 | if (!$field->getDescription()) { 64 | return ''; 65 | } 66 | 67 | return sprintf('%s', wp_kses_post($field->getDescription())); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/WpAdmin/Users/Fields/Type.php: -------------------------------------------------------------------------------- 1 | addFilter(Profile::HOOK_NAME_REGISTER_USER_META_FIELDS, [$this, 'registerUserMetaFields']); 27 | } 28 | 29 | /** 30 | * Get the user meta value. 31 | * @param int $user_id 32 | * @param string $key 33 | * @return mixed 34 | */ 35 | public static function getUserMeta(int $user_id, string $key): mixed 36 | { 37 | return get_user_meta($user_id, $key, true); 38 | } 39 | 40 | /** 41 | * Register new user meta fields. 42 | * @param \TheFrosty\WpUtilities\WpAdmin\Users\Models\UserMetaField[] $fields 43 | * @return \TheFrosty\WpUtilities\WpAdmin\Users\Models\UserMetaField[] 44 | */ 45 | abstract protected function registerUserMetaFields(array $fields): array; 46 | } 47 | -------------------------------------------------------------------------------- /src/WpAdmin/Users/Models/UserMetaField.php: -------------------------------------------------------------------------------- 1 | condition; 60 | } 61 | 62 | /** 63 | * Set field condition. 64 | * @param Closure $condition 65 | */ 66 | protected function setCondition(Closure $condition): void 67 | { 68 | $this->condition = $condition; 69 | } 70 | 71 | /** 72 | * Get field description. 73 | * @return string|null 74 | */ 75 | public function getDescription(): ?string 76 | { 77 | return $this->description; 78 | } 79 | 80 | /** 81 | * Set field description. 82 | * @param string $description 83 | */ 84 | protected function setDescription(string $description): void 85 | { 86 | $this->description = $description; 87 | } 88 | 89 | /** 90 | * Get field label. 91 | * @return string 92 | */ 93 | public function getLabel(): string 94 | { 95 | return $this->label; 96 | } 97 | 98 | /** 99 | * Set field label. 100 | * @param string $label 101 | */ 102 | protected function setLabel(string $label): void 103 | { 104 | $this->label = $label; 105 | } 106 | 107 | /** 108 | * Get field name. 109 | * @return string 110 | */ 111 | public function getName(): string 112 | { 113 | return $this->name; 114 | } 115 | 116 | /** 117 | * Set field name. 118 | * @param string $name 119 | */ 120 | protected function setName(string $name): void 121 | { 122 | $this->name = $name; 123 | } 124 | 125 | /** 126 | * Get field HTML input type. 127 | * @ref https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input 128 | * @ref https://stitcher.io/blog/php-enums 129 | * @return Type 130 | */ 131 | public function getType(): Type 132 | { 133 | return $this->type; 134 | } 135 | 136 | /** 137 | * Get field input type. 138 | * @param Type $type 139 | */ 140 | protected function setType(Type $type): void 141 | { 142 | $this->type = $type; 143 | } 144 | 145 | /** 146 | * Get serializable fields. 147 | * @return string[] 148 | */ 149 | protected function getSerializableFields(): array 150 | { 151 | return [ 152 | self::FIELD_DESCRIPTION, 153 | self::FIELD_LABEL, 154 | self::FIELD_NAME, 155 | self::FIELD_TYPE, 156 | ]; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/WpAdmin/Users/Profile.php: -------------------------------------------------------------------------------- 1 | addAction("{$prefix}_user_profile", [$this, 'userProfile']); 46 | } 47 | $this->addAction('personal_options_update', [$this, 'saveProfileFields']); 48 | $this->addAction('edit_user_profile_update', [$this, 'saveProfileFields']); 49 | } 50 | 51 | /** 52 | * Get all registered UserMetaFields. 53 | * @return UserMetaField[] 54 | */ 55 | protected function getUserMetaFields(): array 56 | { 57 | $fields = (array)apply_filters(self::HOOK_NAME_REGISTER_USER_META_FIELDS, []); 58 | return array_filter($fields, static fn($field): bool => $field instanceof UserMetaField); 59 | } 60 | 61 | /** 62 | * User profile HTML. 63 | * @param WP_User $user The user 64 | */ 65 | protected function userProfile(WP_User $user): void 66 | { 67 | printf('

    %s

    ', esc_html__('Extra Profile Fields', 'wp-utilities')); 68 | 69 | $fields = $this->getUserMetaFields(); 70 | 71 | /** 72 | * Custom action hook to load on the user's edit/show profile. 73 | * @param WP_User $user The user profile object. 74 | * @param UserMetaField[] $fields array of field objects. 75 | */ 76 | do_action(sprintf(self::HOOK_NAME_USER_PROFILE_S, current_action()), $user, $fields); 77 | 78 | (new View())->render( 79 | 'wp-admin/users/profile', 80 | [ 81 | 'current_user' => wp_get_current_user(), 82 | 'fields' => $fields, 83 | 'query' => $this->getRequest()->query, 84 | 'user' => $user, 85 | ] 86 | ); 87 | } 88 | 89 | /** 90 | * Maybe save our custom user fields. 91 | * @param int $user_id 92 | */ 93 | protected function saveProfileFields(int $user_id): void 94 | { 95 | $fields = $this->getUserMetaFields(); 96 | $request = $this->getRequest()->request; 97 | if ( 98 | !$request->has(self::ACTION) || 99 | !wp_verify_nonce($request->get(self::ACTION), sprintf(self::NONCE_ACTION_S, $user_id)) || 100 | !current_user_can('edit_user', $user_id) || 101 | empty($fields) 102 | ) { 103 | return; 104 | } 105 | 106 | foreach ($fields as $field) { 107 | if (!$request->has($field->getName())) { 108 | continue; 109 | } 110 | $prev_value = get_user_meta($user_id, $field->getName(), true); 111 | update_user_meta($user_id, $field->getName(), $request->get($field->getName()), $prev_value); 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/functions.php: -------------------------------------------------------------------------------- 1 | server->get( 30 | 'HTTP_CLIENT_IP', 31 | $request->server->get( 32 | 'HTTP_CF_CONNECTING_IP', 33 | $request->server->get( 34 | 'HTTP_X_FORWARDED', 35 | $request->server->get( 36 | 'HTTP_X_FORWARDED_FOR', 37 | $request->server->get( 38 | 'HTTP_FORWARDED', 39 | $request->server->get( 40 | 'HTTP_FORWARDED_FOR', 41 | $request->server->get('REMOTE_ADDR') 42 | ) 43 | ) 44 | ) 45 | ) 46 | ) 47 | ); 48 | 49 | if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6)) { 50 | return null; 51 | } 52 | 53 | return sanitize_text_field($ip); 54 | } 55 | 56 | /** 57 | * 6.3.0 Stub for PHP 8.0+. 58 | * Registers a new script. 59 | * Registers a script to be enqueued later using the wp_enqueue_script() function. 60 | * @param string $handle Name of the script. Should be unique. 61 | * @param string|false $src Full URL of the script, or path of the script relative to the WordPress root directory. 62 | * If source is set to false, script is an alias of other scripts it depends on. 63 | * @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array. 64 | * @param string|bool|int|null $ver Optional. String specifying script version number, if it has one, which is added to 65 | * the URL as a query string for cache busting purposes. If version is set to false, a version number is 66 | * automatically added equal to current installed WordPress version. If set to null, no version is added. 67 | * @param array|bool $args { 68 | * Optional. An array of additional script loading strategies. Default empty array. 69 | * Otherwise, it may be a boolean in which case it determines whether the script is printed in the footer. Default 70 | * false. 71 | * @type string $strategy Optional. If provided, may be either 'defer' or 'async'. 72 | * @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'. 73 | * } 74 | * @return bool Whether the script has been registered. True on success, false on failure. 75 | * @see WP_Dependencies::add() 76 | * @see WP_Dependencies::add_data() 77 | * @since 2.1.0 78 | * @since 4.3.0 A return value was added. 79 | * @since 6.3.0 The $in_footer parameter of type boolean was overloaded to be an $args parameter of type array. 80 | */ 81 | function wpRegisterScript( 82 | string $handle, 83 | string|false $src, 84 | array $deps = [], 85 | mixed $ver = false, 86 | bool|array $args = [] 87 | ): bool { 88 | if (!is_array($args)) { 89 | $args = [ 90 | 'in_footer' => $args, 91 | ]; 92 | } 93 | 94 | if (version_compare(get_bloginfo('version'), '6.3') >= 0) { 95 | return wp_register_script($handle, $src, $deps, $ver, $args); 96 | } 97 | 98 | return wp_register_script($handle, $src, $deps, $ver, $args['in_footer']); 99 | } 100 | 101 | /** 102 | * 6.3.0 Stub for PHP 8.0+. 103 | * Enqueues a script. 104 | * Registers the script if $src provided (does NOT overwrite), and enqueues it. 105 | * @param string $handle Name of the script. Should be unique. 106 | * @param string|false $src Full URL of the script, or path of the script relative to the WordPress root directory. 107 | * Default empty. 108 | * @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array. 109 | * @param string|bool|int|null $ver Optional. String specifying script version number, if it has one, which is added to 110 | * the URL as a query string for cache busting purposes. If version is set to false, a version number is 111 | * automatically added equal to current installed WordPress version. If set to null, no version is added. 112 | * @param array|bool $args { 113 | * Optional. An array of additional script loading strategies. Default empty array. 114 | * Otherwise, it may be a boolean in which case it determines whether the script is printed in the footer. Default 115 | * false. 116 | * @see WP_Dependencies::add() 117 | * @see WP_Dependencies::add_data() 118 | * @see WP_Dependencies::enqueue() 119 | * @since 2.1.0 120 | * @since 6.3.0 The $in_footer parameter of type boolean was overloaded to be an $args parameter of type array. 121 | */ 122 | function wpEnqueueScript( 123 | string $handle, 124 | string|false $src = '', 125 | array $deps = [], 126 | mixed $ver = false, 127 | bool|array $args = [] 128 | ): void { 129 | if (!is_array($args)) { 130 | $args = [ 131 | 'in_footer' => $args, 132 | ]; 133 | } 134 | 135 | if (version_compare(get_bloginfo('version'), '6.3') >= 0) { 136 | wp_enqueue_script($handle, $src, $deps, $ver, $args); 137 | 138 | return; 139 | } 140 | 141 | wp_enqueue_script($handle, $src, $deps, $ver, $args['in_footer']); 142 | } 143 | -------------------------------------------------------------------------------- /views/dashboard-widget.php: -------------------------------------------------------------------------------- 1 | %s.', esc_attr(DashboardWidget::class))); 14 | } 15 | 16 | $div_open = '
      '; 17 | $div_close = '
    '; 18 | echo $div_open; 19 | $template = match ($this->getWidget()->getType()) { 20 | Widget::TYPE_RSS => __DIR__ . 'dashboard-widget/rss.php', 21 | default => __DIR__ . '/dashboard-widget/rest.php', 22 | }; 23 | include $template; 24 | echo $div_close; 25 | 26 | /** 27 | * Render additional content. 28 | * @param string $div_open The opening div tag. 29 | * @param string $div_close The closing div tag. 30 | * @param string $template The template file to use. 31 | * @param Widget $widget The widget object. 32 | */ 33 | do_action(DashboardWidget::HOOK_NAME_RENDER, $div_open, $div_close, $template, $this->getWidget()); 34 | -------------------------------------------------------------------------------- /views/dashboard-widget/rest.php: -------------------------------------------------------------------------------- 1 | retrieveBodyCached($this->getWidget()->getFeedUrl(), DAY_IN_SECONDS); 9 | $renderContent ??= true; // Pass false to disable rendering the widget content on the first key. 10 | $widgetId ??= $this->getWidget()->getWidgetId(); // Pass the widget ID to the template (outside `DashboardWidget`). 11 | static $count; 12 | 13 | $content = ''; 14 | if (empty($posts)) { 15 | $wpRemote->deleteCache($wpRemote->getQueryCacheKey() ?? ''); 16 | $content .= '
  • ' . __('Error fetching feed') . '
  • '; 17 | } else { 18 | foreach ($posts as $item) { 19 | $count++; 20 | $content .= '
  • '; 21 | $content .= '' . esc_html($item->title->rendered) . ''; 26 | 27 | if ($count === 1 && $renderContent) { 28 | $content .= '   ' . 29 | date_i18n(get_option('date_format'), strtotime($item->date)) . ''; 30 | $content .= '
    ' . 31 | strip_tags(wp_trim_words($item->content->rendered, 28)) . '
    '; 32 | } 33 | $content .= '
  • '; 34 | } 35 | unset($count); 36 | } 37 | 38 | echo $content; 39 | -------------------------------------------------------------------------------- /views/dashboard-widget/rss.php: -------------------------------------------------------------------------------- 1 | getFeedItems(1, $this->getWidget()->getFeedUrl()); 9 | static $count; 10 | 11 | $content = ''; 12 | if (empty($posts)) { 13 | $content .= '
  • ' . __('Error fetching feed') . '
  • '; 14 | } else { 15 | foreach ($posts as $item) { 16 | if (!($item instanceof SimplePie_Item)) { 17 | continue; 18 | } 19 | 20 | $count++; 21 | $content .= '
  • '; 22 | $content .= '' . esc_html($item->get_title()) . ''; 27 | 28 | if ($count === 1) { 29 | $content .= '   ' . 30 | $item->get_date(get_option('date_format')) . ''; 31 | $content .= '
    ' . strip_tags(wp_trim_words($item->get_description(), 28)) . '
    '; 32 | } 33 | $content .= '
  • '; 34 | } 35 | unset($count); 36 | } 37 | 38 | echo $content; 39 | -------------------------------------------------------------------------------- /views/wp-admin/users/profile.php: -------------------------------------------------------------------------------- 1 | query; 14 | $user ??= get_user_by('ID', (int)$query->get('user_id', 0)); 15 | 16 | if (empty($fields)) { 17 | return; 18 | } 19 | 20 | /** 21 | * Build the field type input html. 22 | * @param UserMetaField $field 23 | * @return string 24 | */ 25 | $buildFieldType = static function (UserMetaField $field) use ($user): string { 26 | return (new class { 27 | use FieldType; 28 | })->{$field->getType()->value}($field, $user); 29 | }; 30 | 31 | echo ''; 32 | 33 | foreach ($fields as $field) { 34 | $condition = $field->getCondition(); 35 | if (is_callable($condition) && $condition() === true) { 36 | continue; 37 | } 38 | printf( 39 | ' 40 | 41 | 44 | ', 45 | esc_attr($field->getName()), 46 | esc_html($field->getLabel()), 47 | $buildFieldType($field), 48 | ); 49 | } 50 | wp_nonce_field(sprintf(Profile::NONCE_ACTION_S, $user->ID), Profile::ACTION); 51 | echo '
    42 | %3$s 43 |
    '; 52 | --------------------------------------------------------------------------------