├── .gitignore ├── composer.json ├── composer.lock ├── license.md ├── readme.md └── src ├── Agents ├── AbstractAgent.php ├── AbstractPluginAgent.php ├── AbstractThemeAgent.php ├── AgentInterface.php └── Collection │ ├── AgentCollection.php │ ├── AgentCollectionException.php │ ├── AgentCollectionInterface.php │ └── Factory │ ├── AgentCollectionFactory.php │ └── AgentCollectionFactoryInterface.php ├── Commands ├── AbstractCommand.php ├── Arguments │ ├── AssociativeArgument.php │ ├── Collection │ │ ├── ArgumentCollection.php │ │ ├── ArgumentCollectionException.php │ │ └── ArgumentCollectionInterface.php │ ├── OptionalArgument.php │ └── PositionalArgument.php ├── Collection │ ├── CommandCollection.php │ ├── CommandCollectionException.php │ ├── CommandCollectionInterface.php │ └── Factory │ │ ├── CommandCollectionFactory.php │ │ └── CommandCollectionFactoryInterface.php ├── CommandException.php └── CommandInterface.php ├── Handlers ├── AbstractHandler.php ├── HandlerException.php ├── HandlerInterface.php ├── Plugins │ ├── AbstractMustUsePluginHandler.php │ ├── AbstractPluginHandler.php │ └── PluginHandlerInterface.php └── Themes │ ├── AbstractThemeHandler.php │ └── ThemeHandlerInterface.php ├── Hooks ├── AbstractHook.php ├── ClosureHook.php ├── Collection │ ├── Factory │ │ ├── HookCollectionFactory.php │ │ └── HookCollectionFactoryInterface.php │ ├── HookCollection.php │ ├── HookCollectionException.php │ └── HookCollectionInterface.php ├── Factory │ ├── HookFactory.php │ └── HookFactoryInterface.php ├── HookException.php ├── HookInterface.php └── MethodHook.php ├── Repositories ├── AgentDefinition │ ├── AgentDefinition.php │ └── AgentDefinitionException.php ├── Arguments │ ├── AbstractArgument.php │ ├── ArgumentException.php │ └── ArgumentInterface.php ├── CommandDefinition │ ├── CommandDefinition.php │ └── CommandDefinitionException.php ├── MenuItems │ ├── MenuItem.php │ ├── MenuItemException.php │ ├── MenuItemInterface.php │ └── SubmenuItem.php └── PostValidity.php └── Traits ├── ActionAndNonceTrait.php ├── CaseChangingTrait.php ├── CommandLineTrait.php ├── FormattedDateTimeTrait.php ├── NetworkOptionsManagementTrait.php ├── OptionsManagementTrait.php ├── PostMetaManagementTrait.php ├── PostTypeRegistrationTrait.php └── TaxonomyRegistrationTrait.php /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | 3 | ### JetBrains template 4 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 5 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 6 | 7 | # User-specific stuff 8 | .idea 9 | 10 | # CMake 11 | cmake-build-*/ 12 | 13 | # File-based project format 14 | *.iws 15 | 16 | # IntelliJ 17 | out/ 18 | 19 | # mpeltonen/sbt-idea plugin 20 | .idea_modules/ 21 | 22 | # JIRA plugin 23 | atlassian-ide-plugin.xml 24 | 25 | # Crashlytics plugin (for Android Studio and IntelliJ) 26 | com_crashlytics_export_strings.xml 27 | crashlytics.properties 28 | crashlytics-build.properties 29 | fabric.properties 30 | 31 | ### Node template 32 | # Logs 33 | logs 34 | *.log 35 | npm-debug.log* 36 | yarn-debug.log* 37 | yarn-error.log* 38 | 39 | # Runtime data 40 | pids 41 | *.pid 42 | *.seed 43 | *.pid.lock 44 | 45 | # Directory for instrumented libs generated by jscoverage/JSCover 46 | lib-cov 47 | 48 | # Coverage directory used by tools like istanbul 49 | coverage 50 | 51 | # nyc test coverage 52 | .nyc_output 53 | 54 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 55 | .grunt 56 | 57 | # Bower dependency directory (https://bower.io/) 58 | bower_components 59 | 60 | # node-waf configuration 61 | .lock-wscript 62 | 63 | # Compiled binary addons (https://nodejs.org/api/addons.html) 64 | build/Release 65 | 66 | # Dependency directories 67 | node_modules/ 68 | jspm_packages/ 69 | 70 | # TypeScript v1 declaration files 71 | typings/ 72 | 73 | # Optional npm cache directory 74 | .npm 75 | 76 | # Optional eslint cache 77 | .eslintcache 78 | 79 | # Optional REPL history 80 | .node_repl_history 81 | 82 | # Output of 'npm pack' 83 | *.tgz 84 | 85 | # Yarn Integrity file 86 | .yarn-integrity 87 | 88 | # dotenv environment variables file 89 | .env 90 | 91 | # parcel-bundler cache (https://parceljs.org/) 92 | .cache 93 | 94 | # next.js build output 95 | .next 96 | 97 | # nuxt.js build output 98 | .nuxt 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # Serverless directories 104 | .serverless 105 | 106 | ### Composer template 107 | composer.phar 108 | /vendor/ 109 | 110 | # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control 111 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 112 | # composer.lock 113 | 114 | ### Windows template 115 | # Windows thumbnail cache files 116 | Thumbs.db 117 | ehthumbs.db 118 | ehthumbs_vista.db 119 | 120 | # Dump file 121 | *.stackdump 122 | 123 | # Folder config file 124 | [Dd]esktop.ini 125 | 126 | # Recycle Bin used on file shares 127 | $RECYCLE.BIN/ 128 | 129 | # Windows Installer files 130 | *.cab 131 | *.msi 132 | *.msix 133 | *.msm 134 | *.msp 135 | 136 | # Windows shortcuts 137 | *.lnk 138 | 139 | ### macOS template 140 | # General 141 | .DS_Store 142 | .AppleDouble 143 | .LSOverride 144 | 145 | # Icon must end with two \r 146 | Icon 147 | 148 | # Thumbnails 149 | ._* 150 | 151 | # Files that might appear in the root of a volume 152 | .DocumentRevisions-V100 153 | .fseventsd 154 | .Spotlight-V100 155 | .TemporaryItems 156 | .Trashes 157 | .VolumeIcon.icns 158 | .com.apple.timemachine.donotpresent 159 | 160 | # Directories potentially created on remote AFP share 161 | .AppleDB 162 | .AppleDesktop 163 | Network Trash Folder 164 | Temporary Items 165 | .apdisk 166 | 167 | ### WordPress template 168 | wp-config.php 169 | wp-content/advanced-cache.php 170 | wp-content/backup-db/ 171 | wp-content/backups/ 172 | wp-content/blogs.dir/ 173 | wp-content/cache/ 174 | wp-content/upgrade/ 175 | wp-content/uploads/ 176 | wp-content/mu-plugins/ 177 | wp-content/wp-cache-config.php 178 | wp-content/plugins/hello.php 179 | 180 | /.htaccess 181 | /license.txt 182 | /readme.html 183 | /sitemap.xml 184 | /sitemap.xml.gz 185 | 186 | /tests -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dashifen/wp-handler", 3 | "description": "An object to define handlers for various WordPress action and filter hooks using protected methods. ", 4 | "minimum-stability": "stable", 5 | "type": "project", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "David Dashifen Kees", 10 | "email": "dashifen@dashifen.com", 11 | "role": "developer" 12 | } 13 | ], 14 | "autoload": { 15 | "psr-4": { 16 | "Dashifen\\WPHandler\\": "src" 17 | } 18 | }, 19 | "require": { 20 | "php": ">=8.2", 21 | "dashifen/collection": "^3", 22 | "dashifen/exception": "^1", 23 | "dashifen/repository": "^4", 24 | "dashifen/transformer": "^2", 25 | "dashifen/wp-debugging": "^1" 26 | }, 27 | "require-dev": { 28 | "wp-cli/wp-cli": "^2.5" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Copyright 2019 David Dashifen Kees 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | * No Harm: The software may not be used by anyone for systems or activities that actively and knowingly endanger, harm, or otherwise threaten the physical, mental, economic, or general well-being of other individuals or groups, in violation of the United Nations Universal Declaration of Human Rights (https://www.un.org/en/universal-declaration-human-rights/). 8 | 9 | * Services: If the Software is used to provide a service to others, the licensee shall, as a condition of use, require those others not to use the service in any way that violates the No Harm clause above. 10 | 11 | * Enforceability: If any portion or provision of this License shall to any extent be declared illegal or unenforceable by a court of competent jurisdiction, then the remainder of this License, or the application of such portion or provision in circumstances other than those as to which it is so declared illegal or unenforceable, shall not be affected thereby, and each portion and provision of this Agreement shall be valid and enforceable to the fullest extent permitted by law. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 14 | 15 | This Hippocratic License is an Ethical Source license (https://ethicalsource.dev) derived from the MIT License, amended to limit the impact of the unethical use of open source software. -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # WP Handler 2 | 3 | This object is a way to hide the gritty details of attaching handlers to WordPress action and filter hooks to `protected` methods within an object. Without it, only `public` ones of an object are accessible to the WordPress core ecosystem, but with it we can use `protected` methods as well. 4 | 5 | Wait!? `Protected` methods available to WordPress? That's right! 6 | 7 | With reflection and the __call() method, the classes in this library allow, but do not require, you to use `protected` methods as action/filter callbacks. While this is clearly against the "letter of the law" with respect to object method visibility, it's a nice way to add an additional layer of security to our objects. If `public` methods are an unlocked door into your object's scope, these methods are locked ones. With the right key, WordPress can unlock them and enter, but other plugins, themes, or even WP Core without the right key won't be able to. 8 | 9 | ## Installation 10 | 11 | This package is composer ready, simply ... 12 | 13 | ```shell script 14 | composer install dashifen/wp-handler 15 | ``` 16 | 17 | ... and then get to work! 18 | 19 | 20 | ## Usage 21 | 22 | This library contains a deeply nested series of objects that work together to provide increasing levels of WordPress specificity within your code. Interfaces have been provided, but so have `abstract` objects which we expect will be more useful. 23 | 24 | ### AbstractHandler 25 | 26 | At the top of this hierarchy is simply the `AbstractHandler` object. This one defines a core set of functionality that each of its extensions uses in some way. See its interface for more information. Additionally, there are a number of protected methods within this object that you should use to add and remove WordPress action and filter callbacks from your project's ecosystem. See below for examples. 27 | 28 | ### AbstractThemeHandler 29 | 30 | This is intended as the object that theme objects should extend. It adds three methods—the first two of which are `public` and the last of which is `protected`: `getStylesheetDir`, `getStylesheetUrl`, and `enqueue`. The first two are somewhat self-explanatory; the third enqueues JS and CSS found within the theme's directory without having to go through the rigmarole that we usually execute to do so. 31 | 32 | ### AbstractPluginHandler 33 | 34 | Like the theme handler, it's from here that plugins can be extended. It's more robust that the theme handler offering directory and URL identification assistance and extending the `enqueue` method to add JS and CSS from the plugin's scope. But, it also has methods to activate, deactivate, and uninstall your plugin as well as a number of Dashboard menu manipulation functions for your convenience. 35 | 36 | ### Agents 37 | 38 | Handlers have their agents to perform specific tasks for them that they would otherwise have to do themselves. Thus, Agents are small, focused classes that handle a specific and single responsibility for the Handler thant employs them. For example, post type registration, especially the arrays of labels for the WordPress `register_post_type` function are rather verbose. Moving them to a service object keeps things tidier in your plugin or theme object which makes your life better and may help with maintenance. Got a problem with a type's registration? You're gonna know right where to look! 39 | 40 | There are three abstract Agent objects already defined: a general `AbstractAgent` and then an `AbstractPluginAgent` and an `AbstractThemeAgent` for your convenience. 41 | 42 | ## Hooks 43 | 44 | Callbacks that utilize protected methods must be registered using the `AbstractHandler`'s `addAction` and `addFilter` methods. These, in turn, construct `HookInterface` objects that are used to "remember" the key that WordPress needs to unlock the door these methods represent. That key is, currently, the priority level at which WordPress calls your method and the action or filter hook it's executing. Adding in the argument count is the next reasonable step in ensuring that WP is calling your callback at the right time, place, and with the expected quantity of information. 45 | 46 | A `HookFactoryInterface` is provided in case you need to change the type of Hook that your plugin or theme is using. Simply implement it to create your factory which produces your type of Hook and when constructing your handlers, simply provide them your factory as the argument to their constructors. 47 | 48 | ## MenuItemInterface 49 | 50 | For the plugin handler's menu manipulation methods, a series of objects representing menu items have been included herein. They utilize my [repository](https://github.com/dashifen/repository) objects which allow read-only access to protected properties similar to how these objects offer limited access to protected methods. This is a newer feature of these objects and I'm not sure I'm fully happy with how they turned out. Suggestions welcome! 51 | 52 | # Examples 53 | 54 | ```php 55 | class AwesomePlugin extends AbstractPluginHandler { 56 | public function initialize (): void { 57 | if (!$this->isInitialized()) { 58 | $this->addAction("init", "startSession"); 59 | } 60 | } 61 | 62 | protected function startSession (): void { 63 | if (session_status() !== PHP_SESSION_ACTIVE) { 64 | session_start(); 65 | } 66 | } 67 | ``` 68 | 69 | In the above example, we create a very tiny plugin object. Because the `initialize` method is abstract in our parent, we must implement it here. Notice that we call the `isInitialized` method within it; if this method returns `false` then it will never do so again. This is intended to avoid the possibility that any handler re-initializes its callbacks by accident. It's recommended that you follow this pattern when using these objects. 70 | 71 | Then, we add a single action callback: when WordPress initializes, we want to start a PHP session. Why? Who knows! It's only an example 😅. 72 | 73 | By using the handler's `addAction` method, we construct a `Hook` object that "remembers" the callback for us and keeps track of the key that WordPress will need to unlock our door. So, when WordPress comes knocking during the `init` action at priority 10 (the default), handlers `__call` magic method inspects its "key," determines that it fits our lock, and then lets it in to execute the `startSession` method. 74 | 75 | ```php 76 | class AwesomeTheme extends AbstractThemeHandler { 77 | public function initialize (): void { 78 | if (!$this->isInitialized()) { 79 | $this->addAction("wp_enqueue_scripts", "enqueueAssets"); 80 | } 81 | } 82 | 83 | protected function addAssets (): void { 84 | $this->enqueue("//fonts.googleapis.com/css?family=Iceland:400,700|Droid+Sans:400,700|Droid+Serif:400italic,700italic"); 85 | $this->enqueue("assets/dashifen.css"); 86 | $this->enqueue("assets/dashifen.js"); 87 | } 88 | ``` 89 | 90 | Like a breath of fresh air, isn't it? The `enqueue` method is smart enough to know that the Google fonts are located elsewhere online due to the `//` which precedes their address. The other two, though, will be included from within the assets folder of this theme's directory for us without us having to do the work to identify that theme's directory, etc. because that work will have already been done within the `AbstractThemeHandler` object. 91 | 92 | Note: assets enqueued by our handler functions pass the last modified date of the asset file as the fourth parameter to the WordPress enqueue functions. We hope that this is useful for cache busting purposes as new versions of CSS or JS files will necessarily have new modification dates. 93 | 94 | # Version 11 95 | 96 | Version 11 will support PHP 8. Other expected changes are as follows: 97 | 98 | 1. removal of the CaseChangingTrait from within this package's namespace 99 | 100 | # Provenance 101 | 102 | I wrote this object to use at work with [Engage](https://enga.ge) in Alexandria, VA. They've given me permission to make a copy of it and alter it for my own purposes, which is this repo. Their copy, which is the initial commit into this repo, is their own and I think neither of us guarantee that, after some time passes, that they'll be interchangeable. 103 | 104 | -------------------------------------------------------------------------------- /src/Agents/AbstractAgent.php: -------------------------------------------------------------------------------- 1 | getHookFactory(), 39 | $handler->getHookCollectionFactory() 40 | ); 41 | 42 | $this->handler = $handler; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Agents/AbstractPluginAgent.php: -------------------------------------------------------------------------------- 1 | getHookFactory(), 42 | $handler->getHookCollectionFactory() 43 | ); 44 | 45 | $this->handler = $handler; 46 | } 47 | 48 | /** 49 | * getPluginDir 50 | * 51 | * Returns the path to the directory containing this Service's handler. 52 | * 53 | * @return string 54 | */ 55 | public function getPluginDir(): string 56 | { 57 | return $this->handler->getPluginDir(); 58 | } 59 | 60 | /** 61 | * getPluginUrl 62 | * 63 | * Returns the path to the URL for the directory containing this 64 | * Service's handler. 65 | * 66 | * @return string 67 | */ 68 | public function getPluginUrl(): string 69 | { 70 | return $this->handler->getPluginUrl(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Agents/AbstractThemeAgent.php: -------------------------------------------------------------------------------- 1 | getHookFactory(), 39 | $handler->getHookCollectionFactory() 40 | ); 41 | 42 | $this->handler = $handler; 43 | } 44 | 45 | /** 46 | * getUrl 47 | * 48 | * Returns the URL that corresponds to the folder in which this Service's 49 | * handler is located. 50 | * 51 | * @return string 52 | */ 53 | public function getStylesheetUrl(): string 54 | { 55 | return $this->handler->getStylesheetUrl(); 56 | } 57 | 58 | /** 59 | * getDir 60 | * 61 | * Returns the filesystem path to the folder in which this Service's 62 | * handler is located. 63 | * 64 | * @return string 65 | */ 66 | public function getStylesheetDir(): string 67 | { 68 | return $this->handler->getStylesheetDir(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Agents/AgentInterface.php: -------------------------------------------------------------------------------- 1 | key()); 66 | } 67 | 68 | /** 69 | * offsetGet 70 | * 71 | * Returns the value at the specified index within the collection. Note: 72 | * we can't alter the method's signature, so we can't type hint $index 73 | * here. Instead, we let the phpDocBlock handle that for our IDEs. 74 | * 75 | * @param string $offset 76 | * 77 | * @return AgentInterface|null 78 | */ 79 | public function offsetGet($offset): AgentInterface 80 | { 81 | return parent::offsetGet($offset); 82 | } 83 | 84 | /** 85 | * offsetSet 86 | * 87 | * Adds the value to the collection at the specified index. Note: we 88 | * can't change the method's signature here, so we can't type hint our 89 | * parameters. Instead, we let the phpDocBlock handle it for our IDEs. 90 | * 91 | * @param string $offset 92 | * @param AgentInterface $value 93 | * 94 | * @return void 95 | * @throws AgentCollectionException 96 | */ 97 | public function offsetSet($offset, $value): void 98 | { 99 | if (!($value instanceof AgentInterface)) { 100 | throw new AgentCollectionException( 101 | 'Collection values must implement AgentInterface', 102 | AgentCollectionException::NOT_AN_AGENT 103 | ); 104 | } 105 | 106 | parent::offsetSet($offset, $value); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Agents/Collection/AgentCollectionException.php: -------------------------------------------------------------------------------- 1 | produceAgentCollectionInstance(); 31 | 32 | foreach ($this->agentDefinitions as $agentDefinition) { 33 | 34 | // an agent constructor's first parameter is that agent's handler. 35 | // but, for convenience, we let definitions skip that one if the 36 | // programmer wants to. therefore, here we must check to see if 37 | // the agent definition has a reference to its handler and, if not, 38 | // use our handler parameter to add one. just in case teh array of 39 | // this agent's parameters is empty, notice we use a stdClass 40 | // object and the null coalescing operator to avoid errors. 41 | 42 | $parameters = $agentDefinition->parameters; 43 | $firstParameter = $parameters[0] ?? new stdClass(); 44 | 45 | if (!$this->isHandler($firstParameter)) { 46 | $parameters = $this->addHandler($parameters, $handler); 47 | } 48 | 49 | $instance = new $agentDefinition->agent(...$parameters); 50 | $collection[$agentDefinition->agent] = $instance; 51 | } 52 | 53 | return $collection; 54 | } 55 | 56 | /** 57 | * produceAgentCollectionInterface 58 | * 59 | * This method provides an easy way to override the default use of the 60 | * AgentCollection object herein. Just extend this object and override 61 | * this method and you're good to go! 62 | * 63 | * @return AgentCollectionInterface 64 | */ 65 | protected function produceAgentCollectionInstance(): AgentCollectionInterface 66 | { 67 | return new AgentCollection(); 68 | } 69 | 70 | /** 71 | * isHandler 72 | * 73 | * Returns true if this object is a handler. 74 | * 75 | * @param object $maybeHandler 76 | * 77 | * @return bool 78 | */ 79 | private function isHandler(object $maybeHandler): bool 80 | { 81 | // all handlers must, eventually, implement the HandlerInterface. so, 82 | // if we can find it in our parameter's interfaces, we know that this 83 | // one is good to go. 84 | 85 | return in_array(HandlerInterface::class, class_implements($maybeHandler)); 86 | } 87 | 88 | /** 89 | * addHandler 90 | * 91 | * Adds the HandlerInterface reference to the front of the parameters 92 | * array. 93 | * 94 | * @param array $parameters 95 | * @param HandlerInterface $handler 96 | * 97 | * @return array 98 | */ 99 | private function addHandler(array $parameters, HandlerInterface $handler): array 100 | { 101 | return array_merge([$handler], $parameters); 102 | } 103 | 104 | /** 105 | * registerAgent 106 | * 107 | * A convenience method that constructs an AgentDefinition based on this 108 | * method's parameters and then passes it to the registerAgentDefinition 109 | * method below. 110 | * 111 | * @param string $agent 112 | * @param array ...$parameters 113 | * 114 | * @return void 115 | * @throws RepositoryException 116 | */ 117 | public function registerAgent(string $agent, ...$parameters): void 118 | { 119 | $agentDefinition = new AgentDefinition($agent, ...$parameters); 120 | $this->registerAgentDefinition($agentDefinition); 121 | } 122 | 123 | /** 124 | * registerAgentDefinition 125 | * 126 | * Given the definition for an Agent, stores it so that we can produce a 127 | * collection including it later. 128 | * 129 | * @param AgentDefinition $agent 130 | * 131 | * @return void 132 | */ 133 | public function registerAgentDefinition(AgentDefinition $agent): void 134 | { 135 | $this->agentDefinitions[] = $agent; 136 | } 137 | 138 | /** 139 | * registerAgentDefinitions 140 | * 141 | * Given an array of agent definitions, registers them. 142 | * 143 | * @param AgentDefinition[] $agents 144 | * 145 | * @return void 146 | */ 147 | public function registerAgentDefinitions(array $agents): void 148 | { 149 | // since we can't type hint the values within our parameter array, we 150 | // walk $agents and pass them to registerAgent() above. then, its type 151 | // hint will throw a PHP error if someone passes something other than 152 | // an AgentDefinition here. 153 | 154 | array_walk($agents, [$this, 'registerAgentDefinition']); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Agents/Collection/Factory/AgentCollectionFactoryInterface.php: -------------------------------------------------------------------------------- 1 | setName($name); 36 | $this->setNamespace($namespace); 37 | $this->arguments = $arguments ?? new ArgumentCollection(); 38 | parent::__construct($handler); 39 | } 40 | 41 | /** 42 | * initialize 43 | * 44 | * Sets the initial state of the properties that define this command and, 45 | * rarely, uses addAction and/or addFilter to attach protected methods of 46 | * this object to the ecosystem of WordPress action and filter hooks. 47 | * 48 | * @return void 49 | */ 50 | abstract public function initialize(): void; 51 | 52 | /** 53 | * __get 54 | * 55 | * Returns the value of any of the above listed properties. 56 | * 57 | * @param string $property 58 | * 59 | * @return mixed 60 | * @throws CommandException 61 | */ 62 | public function __get(string $property) 63 | { 64 | if (!property_exists($this, $property)) { 65 | throw new CommandException( 66 | 'Unknown property: ' . $property, 67 | CommandException::UNKNOWN_PROPERTY 68 | ); 69 | } 70 | 71 | return $property === 'longDesc' 72 | ? $this->getLongDesc() 73 | : $this->$property; 74 | } 75 | 76 | /** 77 | * getLongDesc 78 | * 79 | * It's convenient to use HEREDOC syntax when initializing the long 80 | * description of a command. But, that results in a non-standard output 81 | * format when someone runs the WP CLI help command for it. This method 82 | * tries to correct the format when it can. 83 | * 84 | * @link https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.heredoc 85 | * @return string 86 | */ 87 | protected function getLongDesc(): string 88 | { 89 | if (!preg_match('/\r\n|\n|\r/', $this->longDesc)) { 90 | return $this->longDesc; 91 | } 92 | 93 | $lines = preg_split('/\r\n|\n|\r/', $this->longDesc); 94 | 95 | // we want to remove any leading spaces from our long description. but, 96 | // there are some lines that we want indented two spaces in from the 97 | // headings. so, we're going to see what the indentation is on the first 98 | // line and if it's not zero, we'll alter our description to remove that 99 | // number of characters from the front of each line. to get the 100 | // indentation's length, we can subtract the left-trimmed version of our 101 | // first line from it's full length. for example, strlen(' Hello!'); is 102 | // 9 but strlen('Hello!') is 6, so the following line would calculate the 103 | // indentation as 3. 104 | 105 | $indentation = strlen($lines[0]) - strlen(ltrim($lines[0])); 106 | 107 | if ($indentation === 0) { 108 | return $this->longDesc; 109 | } 110 | 111 | array_walk($lines, fn(&$line) => $line = substr($line, $indentation)); 112 | return join(PHP_EOL, $lines); 113 | } 114 | 115 | /** 116 | * addArgument 117 | * 118 | * Adds an argument synopsis to this command agent's argument collection. 119 | * 120 | * @param ArgumentInterface $argument 121 | * 122 | * @return void 123 | */ 124 | public function addArgument(ArgumentInterface $argument): void 125 | { 126 | $this->arguments[$argument->name] = $argument; 127 | } 128 | 129 | /** 130 | * getCallable 131 | * 132 | * Returns a callable function that is run at the time the CLI command is 133 | * executed to complete the work of the command. 134 | * 135 | * @return callable 136 | */ 137 | public function getCallable(): callable 138 | { 139 | // WordPress will execute this callable when the command line tells it to 140 | // and pass the command line arguments and flags to the callable. we, in 141 | // turn, pass those parameters into the method below. this allows WP Core 142 | // to reference our protected execute method and, in turn, allows that 143 | // method to remain a part of the handler/agent ecosystem. 144 | 145 | return fn(array $args, array $flags) => $this->execute($args, $flags); 146 | } 147 | 148 | /** 149 | * execute 150 | * 151 | * Performs the behaviors of this command. 152 | * 153 | * @param array $args 154 | * @param array $flags 155 | * 156 | * @return void 157 | */ 158 | abstract protected function execute(array $args, array $flags): void; 159 | 160 | /** 161 | * getCommandDescription 162 | * 163 | * Returns the full description of the command this agent performs for use 164 | * as the third parameter to the WP_CLI add_command method. 165 | * 166 | * @return array 167 | */ 168 | public function getDescription(): array 169 | { 170 | // we construct this array using the keywords defined in the WP_CLI 171 | // add_command docs which don't exactly match our property names. notice 172 | // that we don't include the command's name. that's because it becomes the 173 | // first parameter of the add_command call; this array is the third. 174 | 175 | $description = [ 176 | 'before_invoke' => $this->beforeInvoke, 177 | 'after_invoke' => $this->afterInvoke, 178 | 'shortdesc' => $this->shortDesc, 179 | 'longdesc' => $this->getLongDesc(), 180 | 'synopsis' => $this->arguments->getSynopsis(), 181 | 'when' => $this->when, 182 | 'is_deferred' => $this->isDeferred, 183 | ]; 184 | 185 | return array_filter($description); 186 | } 187 | 188 | /** 189 | * setName 190 | * 191 | * Sets the name and slug properties. 192 | * 193 | * @param string $name 194 | * 195 | * @return void 196 | */ 197 | public function setName(string $name): void 198 | { 199 | $this->slug = sanitize_title($name); 200 | $this->name = $name; 201 | } 202 | 203 | /** 204 | * setNamespace 205 | * 206 | * Sets the namespace property. 207 | * 208 | * @param string $namespace 209 | * 210 | * @return void 211 | */ 212 | public function setNamespace(string $namespace): void 213 | { 214 | $this->namespace = $namespace; 215 | } 216 | 217 | /** 218 | * getNamespace 219 | * 220 | * Returns this command's namespace or null. 221 | * 222 | * @return string|null 223 | */ 224 | public function getNamespace(): ?string 225 | { 226 | return $this->namespace ?? null; 227 | } 228 | 229 | /** 230 | * setShortDesc 231 | * 232 | * Sets the short description property. 233 | * 234 | * @param string $shortDesc 235 | * 236 | * @return void 237 | * @throws CommandException 238 | */ 239 | public function setShortDesc(string $shortDesc): void 240 | { 241 | // the docs for the WP_CLI::add_command function specify that short 242 | // descriptions are supposed to be less than 80 characters. if this one is 243 | // too long, we'll throw an exception and the developers can fix it. 244 | 245 | if (strlen($shortDesc) > 80) { 246 | throw new CommandException( 247 | 'Short description is too long; 80 characters or less, please.', 248 | CommandException::INVALID_VALUE 249 | ); 250 | } 251 | 252 | $this->shortDesc = $shortDesc; 253 | } 254 | 255 | /** 256 | * setBeforeInvoke 257 | * 258 | * Sets the before invoke property defining behaviors that are executed 259 | * right before the actual command is run. 260 | * 261 | * @param Closure|null $beforeInvoke 262 | * 263 | * @return void 264 | */ 265 | public function setBeforeInvoke(?Closure $beforeInvoke): void 266 | { 267 | $this->beforeInvoke = $beforeInvoke; 268 | } 269 | 270 | /** 271 | * setAfterInvoke 272 | * 273 | * Sets the after invoke property defining behaviors that are executed 274 | * right after the actual command is run. 275 | * 276 | * @param Closure|null $afterInvoke 277 | * 278 | * @return void 279 | */ 280 | public function setAfterInvoke(?Closure $afterInvoke): void 281 | { 282 | $this->afterInvoke = $afterInvoke; 283 | } 284 | 285 | /** 286 | * setLongDesc 287 | * 288 | * Sets the long description property. 289 | * 290 | * @param string $longDesc 291 | * 292 | * @return void 293 | */ 294 | public function setLongDesc(string $longDesc): void 295 | { 296 | $this->longDesc = $longDesc; 297 | } 298 | 299 | /** 300 | * setWhen 301 | * 302 | * Sets the when property. 303 | * 304 | * @param string $when 305 | * 306 | * @return void 307 | */ 308 | public function setWhen(string $when): void 309 | { 310 | // the default list of hooks can be found in the command cookbook here: 311 | // https://make.wordpress.org/cli/handbook/references/internal-api/wp-cli-add-hook/#notes 312 | // we don't limit our when parameter to only the ones in that list because 313 | // commands can add their own hooks via the WP_CLI::do_hook() function. 314 | 315 | $this->when = $when; 316 | } 317 | 318 | /** 319 | * setIsDeferred 320 | * 321 | * Sets the property determining whether or not this command has been 322 | * deferred. Honestly: the docs don't really tell us what this is, but for 323 | * the sake of being complete, we've included it. 324 | * 325 | * @param bool $isDeferred 326 | */ 327 | public function setIsDeferred(bool $isDeferred): void 328 | { 329 | $this->isDeferred = $isDeferred; 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/Commands/Arguments/AssociativeArgument.php: -------------------------------------------------------------------------------- 1 | $name, 49 | 'type' => 'assoc', 50 | 'description' => $description, 51 | 'options' => $options, // null options means all values are valid 52 | 'default' => $default, // default will be come first option as needed 53 | 54 | // honestly, we're not sure if WP even allows repeating associative 55 | // arguments, but nothing in the docs say that they can't repeat, so 56 | // we'll allow it. 57 | 58 | 'repeating' => $repeating, 59 | ]); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Commands/Arguments/Collection/ArgumentCollection.php: -------------------------------------------------------------------------------- 1 | collection as $argument) { 94 | 95 | // first, we remove any blank indices from our array using array_filter. 96 | // then, the CLI wants Boolean values to be listed as strings, so we do 97 | // a quick conversion after that. 98 | 99 | $argumentArray = array_filter($argument->toArray()); 100 | 101 | foreach ($argumentArray as &$value) { 102 | if (is_bool($value)) { 103 | $value = $value ? 'true' : 'false'; 104 | } 105 | } 106 | 107 | $synopsis[] = $argumentArray; 108 | } 109 | 110 | return $synopsis ?? []; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Commands/Arguments/Collection/ArgumentCollectionException.php: -------------------------------------------------------------------------------- 1 | $name, 37 | 'type' => 'flag', 38 | 'description' => $description, 39 | ]); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Commands/Arguments/PositionalArgument.php: -------------------------------------------------------------------------------- 1 | $name, 38 | 'type' => 'positional', 39 | 'description' => $description, 40 | 'repeating' => $repeating, 41 | 'optional' => false, 42 | ]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Commands/Collection/CommandCollection.php: -------------------------------------------------------------------------------- 1 | produceCommandCollectionInstance(); 34 | 35 | foreach ($this->commandDefinitions as $definition) { 36 | 37 | // just like our Agents, the first parameter of a Command's constructor 38 | // is a handler. unlike them, the second parameter must be the name of 39 | // an ArgumentCollectionInterface object. so, we'll check on those two 40 | // requirements here and make sure we have what we need to construct our 41 | // command. 42 | 43 | $parameters = $definition->parameters; 44 | $maybeHandler = $parameters[0] ?? new stdClass(); 45 | $maybeCollection = $parameters[1] ?? $definition->argumentCollection; 46 | $parameters = array_slice($parameters, 3); 47 | 48 | // for our collection, the $maybeCollection string is hopefully the 49 | // name of something that implements the ArgumentCollectionInterface. if 50 | // not, we can default to the ArgumentCollection object that is included 51 | // within this package. otherwise, we instantiate the one specified in 52 | // the command's definition. 53 | 54 | $collection = !$this->hasInterface($maybeCollection, ArgumentCollectionInterface::class) 55 | ? new ArgumentCollection() 56 | : new $maybeCollection(); 57 | 58 | // now, we want to add that collection to the front of our parameters. 59 | // this makes it the first one momentarily, but once we add our handler 60 | // it becomes the second which is right where it ought to be. 61 | 62 | array_unshift($parameters, $collection); 63 | 64 | // for our handler, either the $maybeHandler variable is already an 65 | // instance of a HandlerInterface object or we'll use the one passed here 66 | // as a parameter. once we figure out which one to use, we'll add that 67 | // to the front of the array, too. 68 | 69 | $handler = $this->hasInterface($maybeHandler, HandlerInterface::class) 70 | ? $maybeHandler 71 | : $handler; 72 | 73 | array_unshift($parameters, $handler); 74 | 75 | // at this point, our parameters are a handler followed by an argument 76 | // collection, followed by any additional parameters specified by this 77 | // command's definition. we'll run things through the array_values 78 | // function just to be sure that the indices are all numeric and then 79 | // construct our command. 80 | 81 | $command = new $definition->command(...$parameters); 82 | $collection[$definition->command] = $command; 83 | } 84 | 85 | return $collection; 86 | } 87 | 88 | /** 89 | * hasInterface 90 | * 91 | * Returns true if $object implements $interface. note: $object might be 92 | * an object or simply the class name of an object, so we can't type hint it 93 | * at this time. 94 | * 95 | * @param object|string $object 96 | * @param string $interface 97 | * 98 | * @return bool 99 | */ 100 | private function hasInterface($object, string $interface): bool 101 | { 102 | return is_array($interfaces = class_implements($object)) 103 | && in_array($interface, $interfaces); 104 | } 105 | 106 | /** 107 | * produceCommandCollectionInstance 108 | * 109 | * In case someone wants to use a collection other than the default, this 110 | * method can be overridden by extensions to produce a different collection 111 | * object as long as it implements the CommandCollectionInterface, too. 112 | * 113 | * @return CommandCollectionInterface 114 | */ 115 | private function produceCommandCollectionInstance(): CommandCollectionInterface 116 | { 117 | return new CommandCollection(); 118 | } 119 | 120 | /** 121 | * registerCommand 122 | * 123 | * Prepares this Factory to include a command as a part of the collection it 124 | * produces 125 | * 126 | * @param string $command 127 | * 128 | * @return void 129 | * @throws RepositoryException 130 | */ 131 | public function registerCommand(string $command): void 132 | { 133 | $definition = new CommandDefinition($command); 134 | $this->registerCommandDefinition($definition); 135 | } 136 | 137 | /** 138 | * registerCommandDefinition 139 | * 140 | * Registers a command definition within our factory so that it can produce 141 | * it within a collection when requested to do so. 142 | * 143 | * @param CommandDefinition $command 144 | */ 145 | public function registerCommandDefinition(CommandDefinition $command): void 146 | { 147 | $this->commandDefinitions[] = $command; 148 | } 149 | 150 | /** 151 | * registerCommandDefinitions 152 | * 153 | * Given an array of command definitions, registers all of then at once. 154 | * 155 | * @param CommandDefinition[] $commands 156 | * 157 | * @return void 158 | */ 159 | public function registerCommandDefinitions(array $commands): void 160 | { 161 | array_walk($commands, [$this, 'registerCommandDefinition']); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Commands/Collection/Factory/CommandCollectionFactoryInterface.php: -------------------------------------------------------------------------------- 1 | hookFactory = $hookFactory ?? new HookFactory(); 50 | 51 | // from this abstract class descends all of our Handlers and Agents, each 52 | // of which should have their own hook collection, we don't pass around the 53 | // collection itself, we pass the factory which makes them. that way, 54 | // every Handler and all of its Agents gets their own collection rather 55 | // than trying to share a single one. then, we store the factory locally, 56 | // too, because any Agents this Handler employs will need it, too. 57 | 58 | $hookCollectionFactory = $hookCollectionFactory ?? new HookCollectionFactory(); 59 | $this->hookCollection = $hookCollectionFactory->produceHookCollection(); 60 | $this->hookCollectionFactory = $hookCollectionFactory; 61 | $this->handlerReflection = new ReflectionClass($this); 62 | } 63 | 64 | /** 65 | * __call 66 | * 67 | * Checks to see if the method being called is in the $hooked 68 | * property, and if so, calls it passing the $arguments to it. 69 | * 70 | * @param string $method 71 | * @param array $arguments 72 | * 73 | * @return mixed 74 | * @throws HandlerException 75 | */ 76 | public function __call(string $method, array $arguments) 77 | { 78 | // getting here should only happen via WordPress callbacks and only for 79 | // MethodHooks; closures aren't a part of an object so they won't ever get 80 | // called like this. once we get here, we want to see if there's a method 81 | // in our hook collection that corresponds to this action and priority. 82 | 83 | $action = current_action(); 84 | 85 | if (empty($action)) { 86 | throw new HandlerException( 87 | "Unable to determine action/filter at which $method was called", 88 | HandlerException::INAPPROPRIATE_CALL 89 | ); 90 | } 91 | 92 | $priority = has_filter($action, [$this, $method]); 93 | $hookIndex = $this->hookFactory->produceHookIndex($action, $this, $method, $priority); 94 | if (!$this->hookCollection[$hookIndex]) { 95 | // if we're in here, then we don't have a Hook that exactly matches 96 | // this method, action, and priority combination. since we're about 97 | // to crash out of things anyway, we'll see if we can help the 98 | // programmer identify the problem. 99 | 100 | foreach ($this->hookCollection as $hook) { 101 | if ($hook->method === $method) { 102 | // well, we just found a hook using this method, so the problem 103 | // must be that we're at the wrong action or priority. let's see 104 | // if which it is. 105 | 106 | if ($hook->hook !== $action) { 107 | throw new HandlerException( 108 | "$method is hooked but not via $action", 109 | HandlerException::INAPPROPRIATE_CALL 110 | ); 111 | } 112 | 113 | if ($hook->priority !== $priority) { 114 | throw new HandlerException( 115 | "$method is hooked but not at $priority", 116 | HandlerException::INAPPROPRIATE_CALL 117 | ); 118 | } 119 | } 120 | 121 | // if we looped over all of our hooked methods and never threw any of 122 | // the above exceptions, then the only remaining option is that the 123 | // method was never hooked the first place. we have an exception for 124 | // that, too. 125 | 126 | throw new HandlerException( 127 | "Unhooked method: $method.", 128 | HandlerException::UNHOOKED_METHOD 129 | ); 130 | } 131 | } 132 | 133 | // in keeping with WP Core's WP_Hook::apply_filters method, we want to 134 | // remove any extra arguments before passing them over. this is not 135 | // likely too problematic unless we have a variadic method that might 136 | // do something to/with unexpected parameters. so, before we call our 137 | // method, we use array_slice to remove extra arguments. 138 | 139 | $hook = $this->hookCollection[$hookIndex]; 140 | $arguments = array_slice($arguments, 0, $hook->argumentCount); 141 | return $this->{$method}(...$arguments); 142 | } 143 | 144 | /** 145 | * toString 146 | * 147 | * Returns the name of this object using the late-static binding so it'll 148 | * return the name of the concrete handler, not simply "AbstractHandler." 149 | * 150 | * @return string 151 | */ 152 | public function __toString(): string 153 | { 154 | return static::class; 155 | } 156 | 157 | /** 158 | * initialize 159 | * 160 | * Uses addAction and/or addFilter to attach protected methods of this object 161 | * to the ecosystem of WordPress action and filter hooks. 162 | * 163 | * @return void 164 | */ 165 | abstract public function initialize(): void; 166 | 167 | /** 168 | * getHookFactory 169 | * 170 | * Returns the hook factory property. 171 | * 172 | * @return HookFactoryInterface 173 | */ 174 | public function getHookFactory(): HookFactoryInterface 175 | { 176 | return $this->hookFactory; 177 | } 178 | 179 | /** 180 | * getHookCollection 181 | * 182 | * Returns the hook collection property. 183 | * 184 | * @return HookCollectionInterface 185 | */ 186 | public function getHookCollection(): HookCollectionInterface 187 | { 188 | return $this->hookCollection; 189 | } 190 | 191 | /** 192 | * getHookCollection 193 | * 194 | * Returns the hook collection factory property. 195 | * 196 | * @return HookCollectionFactoryInterface 197 | */ 198 | public function getHookCollectionFactory(): HookCollectionFactoryInterface 199 | { 200 | return $this->hookCollectionFactory; 201 | } 202 | 203 | /** 204 | * getAgentCollection 205 | * 206 | * In the unlikely event that an external scope needs a reference to this 207 | * Handler's agent collection, this returns that property. 208 | * 209 | * @return AgentCollectionInterface 210 | */ 211 | public function getAgentCollection(): AgentCollectionInterface 212 | { 213 | return $this->agentCollection; 214 | } 215 | 216 | /** 217 | * setAgentCollection 218 | * 219 | * Given an agent collection factory, produces an agent collection and 220 | * saves it in our properties. 221 | * 222 | * @param AgentCollectionFactoryInterface $agentCollectionFactory 223 | * 224 | * @return void 225 | * @throws AgentCollectionException 226 | */ 227 | public function setAgentCollection(AgentCollectionFactoryInterface $agentCollectionFactory): void 228 | { 229 | // and this is why we have a setter for our agent collection and don't 230 | // define an agent collection factory as a dependency of our constructor: 231 | // the factory needs to know who the handler will be for its agents. 232 | 233 | $this->agentCollection = $agentCollectionFactory->produceAgentCollection($this); 234 | } 235 | 236 | /** 237 | * isInitialized 238 | * 239 | * Returns the value of the initialized property at the start of the method 240 | * but also sets that value to true. This function should be called when 241 | * initializing handlers if you need to avoid re-initialization problems. 242 | * 243 | * @return bool 244 | */ 245 | final protected function isInitialized(): bool 246 | { 247 | $returnValue = $this->initialized; 248 | $this->initialized = true; 249 | return $returnValue; 250 | } 251 | 252 | /** 253 | * initializeAgents 254 | * 255 | * This is merely an opinionated suggestion for how a Handler with Agents 256 | * might initialize them. Concrete extensions of this object are free to 257 | * use, extend, or ignore this one as they see fit. 258 | * 259 | * @return void 260 | */ 261 | protected function initializeAgents(): void 262 | { 263 | // our agent collection implements the Iterable interface so we can 264 | // use a foreach to loop over each of the Agents that it has set within 265 | // it's internal array. then, we just call their initialize methods 266 | // in sequence. 267 | 268 | if ($this->agentCollection instanceof AgentCollectionInterface) { 269 | foreach ($this->agentCollection as $agent) { 270 | $agent->initialize(); 271 | } 272 | } 273 | } 274 | 275 | /** 276 | * addAction 277 | * 278 | * Passes its arguments to add_action() and adds a Hook to our collection. 279 | * 280 | * @param string $hook 281 | * @param string|Closure $callback 282 | * @param int $priority 283 | * @param int $arguments 284 | * 285 | * @return string 286 | * @throws HandlerException 287 | */ 288 | protected function addAction(string $hook, $callback, int $priority = 10, int $arguments = 1): string 289 | { 290 | if (!$this->isValidCallback($callback)) { 291 | throw new HandlerException( 292 | $this->getInvalidCallbackMessage($callback), 293 | HandlerException::INVALID_CALLBACK 294 | ); 295 | } 296 | 297 | $this->addHookToCollection($hook, $callback, $priority, $arguments); 298 | 299 | // if $callback is a string, then we need to add our action to WP using 300 | // the array syntax for method calls. otherwise, we can just pass it 301 | // over to add_action since it is, itself, callable. 302 | 303 | return is_string($callback) 304 | ? add_action($hook, [$this, $callback], $priority, $arguments) 305 | : add_action($hook, $callback, $priority, $arguments); 306 | } 307 | 308 | /** 309 | * isValidCallback 310 | * 311 | * Given a callback, returns true if it's valid, false otherwise. 312 | * 313 | * @param string|Closure $callback 314 | * 315 | * @return bool 316 | */ 317 | protected function isValidCallback($callback): bool 318 | { 319 | // if $callback is a Closure, we're fine. it's the string case that's 320 | // more difficult so we'll bug out before worrying about anything else 321 | // here. 322 | 323 | if ($callback instanceof Closure) { 324 | return true; 325 | } 326 | 327 | // now, if we're here, then $callback better be a string and, if so, it 328 | // also has to be a non-private method of this object. we can use our 329 | // reflection to handle these tests. 330 | 331 | try { 332 | if (!isset($this->reflectionMethods[$callback])) { 333 | $this->reflectionMethods[$callback] = $this->handlerReflection->getMethod($callback); 334 | } 335 | 336 | return !$this->reflectionMethods[$callback]->isPrivate(); 337 | } catch (ReflectionException $e) { 338 | 339 | // the getMethod method throws an exception when the requested 340 | // method doesn't exist. if it doesn't exist, then it can't be a 341 | // callback, so we can just return false here. 342 | 343 | return false; 344 | } 345 | } 346 | 347 | /** 348 | * getInvalidCallbackMessage 349 | * 350 | * Returns an exception message based on the type of $callback. 351 | * 352 | * @param string|object $callback 353 | * 354 | * @return string 355 | */ 356 | private function getInvalidCallbackMessage($callback): string 357 | { 358 | // like the isValidCallback method above, this one uses the type of 359 | // $callback to return an exception message about it's invalidity. 360 | 361 | if (is_string($callback)) { 362 | 363 | // if it's a string, then either (a) it wasn't a method of our 364 | // object or (b) it was private. we'll return a message based on 365 | // which it was here. 366 | 367 | return $this->handlerReflection->hasMethod($callback) 368 | ? $callback . ' must be public or protected' 369 | : 'Method not found: ' . $callback; 370 | } 371 | 372 | // if $callback wasn't a string, it must be an object, but that object 373 | // must not have been a Closure or it would have been valid. so, we'll 374 | // simply request a method or Closure here. 375 | 376 | return 'Callbacks must be a handler method or Closure'; 377 | } 378 | 379 | /** 380 | * addHookToCollection 381 | * 382 | * Given data about a hook, produces one and add it to our collection. 383 | * 384 | * @param string $hook 385 | * @param string|Closure $callback 386 | * @param int $priority 387 | * @param int $arguments 388 | * 389 | * @return void 390 | * @throws HandlerException 391 | */ 392 | private function addHookToCollection(string $hook, $callback, int $priority, int $arguments): void 393 | { 394 | try { 395 | // to add a hook to our collection, we need the index it'll use therein 396 | // and the actually HookInterface implementation that we store. we make 397 | // those and then pass them to our collection's set method. 398 | 399 | $hookIndex = $this->hookFactory->produceHookIndex($hook, $this, $callback, $priority); 400 | $hookObject = $this->hookFactory->produceHook($hook, $this, $callback, $priority, $arguments); 401 | $this->hookCollection[$hookIndex] = $hookObject; 402 | } catch (HookException $exception) { 403 | // to make things easier on the calling scope, we'll "merge" the two 404 | // types of exceptions thrown by the hook collection here into a single 405 | // type: our HandlerException. 406 | 407 | throw new HandlerException( 408 | $exception->getMessage(), 409 | HandlerException::FAILURE_TO_HOOK, 410 | $exception 411 | ); 412 | } 413 | } 414 | 415 | /** 416 | * removeAction 417 | * 418 | * Removes a hooked method from WP core and the record of the hook from our 419 | * collection. Note: closures cannot be removed at this time because they 420 | * cannot be removed using WP's remove_action. 421 | * 422 | * @param string $hook 423 | * @param string $method 424 | * @param int $priority 425 | * 426 | * @return bool 427 | */ 428 | protected function removeAction(string $hook, string $method, int $priority = 10): bool 429 | { 430 | $this->removeHookFromCollection($hook, $method, $priority); 431 | return remove_action($hook, [$this, $method], $priority); 432 | } 433 | 434 | /** 435 | * removeHookFromCollection 436 | * 437 | * Given the information about a hook in our collection, removes it. 438 | * 439 | * @param string $hook 440 | * @param string $method 441 | * @param int $priority 442 | * 443 | * @return void 444 | */ 445 | private function removeHookFromCollection(string $hook, string $method, int $priority): void 446 | { 447 | $hookIndex = $this->hookFactory->produceHookIndex($hook, $this, $method, $priority); 448 | unset($this->hookCollection[$hookIndex]); 449 | } 450 | 451 | /** 452 | * addFilter 453 | * 454 | * Passes its arguments to add_filter and adds a Hook to our collection. 455 | * 456 | * @param string $hook 457 | * @param string|Closure $callback 458 | * @param int $priority 459 | * @param int $arguments 460 | * 461 | * @return string 462 | * @throws HandlerException 463 | */ 464 | protected function addFilter(string $hook, $callback, int $priority = 10, int $arguments = 1): string 465 | { 466 | if (!$this->isValidCallback($callback)) { 467 | throw new HandlerException( 468 | $this->getInvalidCallbackMessage($callback), 469 | HandlerException::INVALID_CALLBACK 470 | ); 471 | } 472 | 473 | $this->addHookToCollection($hook, $callback, $priority, $arguments); 474 | 475 | // based on the type of $callback, we can handle the arguments to the WP 476 | // add_filter function like we did in addAction above. 477 | 478 | return is_string($callback) 479 | ? add_filter($hook, [$this, $callback], $priority, $arguments) 480 | : add_filter($hook, $callback, $priority, $arguments); 481 | } 482 | 483 | /** 484 | * removeFilter 485 | * 486 | * Removes a filter from WP and the record of the Hook from our collection. 487 | * Note: closures cannot be removed at this time because closures cannot be 488 | * removed using WP's remove_filter. 489 | * 490 | * @param string $hook 491 | * @param string $method 492 | * @param int $priority 493 | * 494 | * @return bool 495 | */ 496 | protected function removeFilter(string $hook, string $method, int $priority = 10): bool 497 | { 498 | $this->removeHookFromCollection($hook, $method, $priority); 499 | return remove_filter($hook, [$this, $method], $priority); 500 | } 501 | } 502 | -------------------------------------------------------------------------------- /src/Handlers/HandlerException.php: -------------------------------------------------------------------------------- 1 | / for the file 25 | * in which the WP plugin header is located. 26 | * 27 | * @param bool $withoutDir 28 | * 29 | * @return string 30 | */ 31 | public function getPluginFilename(bool $withoutDir = false): string; 32 | 33 | /** 34 | * getPluginFilenameWithoutDirectory 35 | * 36 | * A convenience method that calls getPluginFilename and passes it a true 37 | * flag. This helps make code utilizing this object a little more self- 38 | * explanatory. 39 | * 40 | * @return string 41 | */ 42 | public function getPluginFilenameWithoutDirectory(): string; 43 | 44 | /** 45 | * getPluginDir 46 | * 47 | * Returns the path to the directory containing this plugin. 48 | * 49 | * @return string 50 | */ 51 | public function getPluginDir(): string; 52 | 53 | /** 54 | * getPluginUrl 55 | * 56 | * Returns the path to the URL for the directory containing this plugin. 57 | * 58 | * @return string 59 | */ 60 | public function getPluginUrl(): string; 61 | 62 | /** 63 | * getPluginData 64 | * 65 | * Returns information about this plugin internally using the WP Core 66 | * get_plugin_data function. 67 | * 68 | * @param string $datum 69 | * @param string $default 70 | * 71 | * @return string 72 | */ 73 | public function getPluginData(string $datum, string $default = ''): string; 74 | 75 | /** 76 | * registerActivationHook 77 | * 78 | * Hooks the method provided to the WordPress ecosystem so that the 79 | * method is executed when this plugin is activated. 80 | * 81 | * @param string $method 82 | * 83 | * @return string 84 | */ 85 | public function registerActivationHook(string $method): string; 86 | 87 | /** 88 | * registerDeactivationHook 89 | * 90 | * Hooks the method provided to the WordPress ecosystem so that the 91 | * method is executed when this plugin is deactivated. 92 | * 93 | * @param string $method 94 | * 95 | * @return string 96 | */ 97 | public function registerDeactivationHook(string $method): string; 98 | 99 | /** 100 | * addMenuPage 101 | * 102 | * A wrapper for the WordPress core function of similar name that registers 103 | * the callback function as a Hook. 104 | * 105 | * @param MenuItem $menuItem 106 | * 107 | * @return string 108 | */ 109 | public function addMenuPage(MenuItem $menuItem): string; 110 | 111 | /** 112 | * wpAddMenuPage 113 | * 114 | * This function calls the prior one by constructing a MenuItem object 115 | * first and then calling the other one. The purpose of this is to provide 116 | * a method with the same arguments as the core add_menu_page() function for 117 | * those that don't want to manage their own MenuItem objects. 118 | * 119 | * @param string $pageTitle 120 | * @param string $menuTitle 121 | * @param string $capability 122 | * @param string $menuSlug 123 | * @param string $method 124 | * @param string $iconUrl 125 | * @param int|null $position 126 | * 127 | * @return mixed 128 | */ 129 | public function wpAddMenuPage(string $pageTitle, string $menuTitle, string $capability, string $menuSlug, string $method, string $iconUrl = "", ?int $position = null); 130 | 131 | /** 132 | * addSubmenuPage 133 | * 134 | * A wrapper for the WordPress core function of similar name that registers 135 | * the callback function as a Hook. 136 | * 137 | * @param SubmenuItem $submenuItem 138 | * 139 | * @return string 140 | */ 141 | public function addSubmenuPage(SubmenuItem $submenuItem): string; 142 | 143 | /** 144 | * wpAddSubmenuPage 145 | * 146 | * Like the wpAddMenuPage, this method provides the means to add a submenu 147 | * item with arguments that match the WordPress world for those who don't 148 | * want to manage their own SubmenuItem objects. 149 | * 150 | * @param string $parentSlug 151 | * @param string $pageTitle 152 | * @param string $menuTitle 153 | * @param string $capability 154 | * @param string $menuSlug 155 | * @param string $method 156 | * 157 | * @return string 158 | */ 159 | public function wpAddSubmenuPage(string $parentSlug, string $pageTitle, string $menuTitle, string $capability, string $menuSlug, string $method): string; 160 | 161 | /** 162 | * addDashboardPage 163 | * 164 | * A wrapper for the WordPress core function of similar name that registers 165 | * the callback function as a Hook. 166 | * 167 | * @param SubmenuItem $submenuItem 168 | * 169 | * @return string 170 | */ 171 | public function addDashboardPage(SubmenuItem $submenuItem): string; 172 | 173 | /** 174 | * addPostsPage 175 | * 176 | * A wrapper for the WordPress core function of similar name that registers 177 | * the callback function as a Hook. 178 | * 179 | * @param SubmenuItem $submenuItem 180 | * 181 | * @return string 182 | */ 183 | public function addPostsPage(SubmenuItem $submenuItem): string; 184 | 185 | /** 186 | * addMediaPage 187 | * 188 | * A wrapper for the WordPress core function of similar name that registers 189 | * the callback function as a Hook. 190 | * 191 | * @param SubmenuItem $submenuItem 192 | * 193 | * @return string 194 | */ 195 | public function addMediaPage(SubmenuItem $submenuItem): string; 196 | 197 | /** 198 | * addCommentsPage 199 | * 200 | * A wrapper for the WordPress core function of similar name that registers 201 | * the callback function as a Hook. 202 | * 203 | * @param SubmenuItem $submenuItem 204 | * 205 | * @return string 206 | */ 207 | public function addCommentsPage(SubmenuItem $submenuItem): string; 208 | 209 | /** 210 | * addThemePage 211 | * 212 | * A wrapper for the WordPress core function of similar name that registers 213 | * the callback function as a Hook. 214 | * 215 | * @param SubmenuItem $submenuItem 216 | * 217 | * @return string 218 | */ 219 | public function addThemePage(SubmenuItem $submenuItem): string; 220 | 221 | /** 222 | * addAppearancePage 223 | * 224 | * A wrapper for the addThemePage function because the name of the WP 225 | * Dashboard menu item is "appearance" and not "theme." 226 | * 227 | * @param SubmenuItem $submenuItem 228 | * 229 | * @return string 230 | */ 231 | public function addAppearancePage(SubmenuItem $submenuItem): string; 232 | 233 | /** 234 | * addPluginsPage 235 | * 236 | * A wrapper for the WordPress core function of similar name that registers 237 | * the callback function as a Hook. 238 | * 239 | * @param SubmenuItem $submenuItem 240 | * 241 | * @return string 242 | */ 243 | public function addPluginsPage(SubmenuItem $submenuItem): string; 244 | 245 | /** 246 | * addUsersPage 247 | * 248 | * A wrapper for the WordPress core function of similar name that registers 249 | * the callback function as a Hook. 250 | * 251 | * @param SubmenuItem $submenuItem 252 | * 253 | * @return string 254 | */ 255 | public function addUsersPage(SubmenuItem $submenuItem): string; 256 | 257 | /** 258 | * addManagementPage 259 | * 260 | * A wrapper for the WordPress core function of similar name that registers 261 | * the callback function as a Hook. 262 | * 263 | * @param SubmenuItem $submenuItem 264 | * 265 | * @return string 266 | */ 267 | public function addManagementPage(SubmenuItem $submenuItem): string; 268 | 269 | /** 270 | * addToolsPage 271 | * 272 | * A wrapper for the addManagementPage method because this one includes the 273 | * name of the menu item in the Dashboard to which this submenu item would be 274 | * added. 275 | * 276 | * @param SubmenuItem $submenuItem 277 | * 278 | * @return string 279 | */ 280 | public function addToolsPage(SubmenuItem $submenuItem): string; 281 | 282 | /** 283 | * addOptionsPage 284 | * 285 | * A wrapper for the WordPress core function of similar name that registers 286 | * the callback function as a Hook. 287 | * 288 | * @param SubmenuItem $submenuItem 289 | * 290 | * @return string 291 | */ 292 | public function addOptionsPage(SubmenuItem $submenuItem): string; 293 | 294 | /** 295 | * addSettingsPage 296 | * 297 | * A wrapper for the addOptionsPage method because this one includes the 298 | * name of the menu item in the Dashboard to which this submenu item would be 299 | * added. 300 | * 301 | * @param SubmenuItem $submenuItem 302 | * 303 | * @return string 304 | */ 305 | public function addSettingsPage(SubmenuItem $submenuItem): string; 306 | 307 | /** 308 | * addPostTypePage 309 | * 310 | * A convenience function that allows for easier registration of submenu 311 | * pages within the menu for a custom post type. 312 | * 313 | * @param string $postType 314 | * @param SubmenuItem $submenuItem 315 | * 316 | * @return string 317 | */ 318 | public function addPostTypePage(string $postType, SubmenuItem $submenuItem): string; 319 | 320 | /** 321 | * addPagesPage 322 | * 323 | * A wrapper for the addPostTypePage that specifically adds a submenu item 324 | * to the Pages menu since that's a standard CPT within WordPress. 325 | * 326 | * @param SubmenuItem $submenuItem 327 | * 328 | * @return string 329 | */ 330 | public function addPagesPage(SubmenuItem $submenuItem): string; 331 | } 332 | -------------------------------------------------------------------------------- /src/Handlers/Themes/AbstractThemeHandler.php: -------------------------------------------------------------------------------- 1 | stylesheetUrl = get_stylesheet_directory_uri(); 46 | $this->stylesheetDir = get_stylesheet_directory(); 47 | } 48 | } 49 | 50 | /** 51 | * getUrl 52 | * 53 | * Returns the URL that corresponds to the folder in which this Handler 54 | * is located. 55 | * 56 | * @return string 57 | */ 58 | public function getStylesheetUrl(): string 59 | { 60 | return $this->stylesheetUrl; 61 | } 62 | 63 | /** 64 | * getDir 65 | * 66 | * Returns the filesystem path to the folder in which this Handler is 67 | * located. 68 | * 69 | * @return string 70 | */ 71 | public function getStylesheetDir(): string 72 | { 73 | return $this->stylesheetDir; 74 | } 75 | 76 | /** 77 | * getThemeData 78 | * 79 | * Returns information about this theme as per the information retrievable 80 | * by the wp_get_theme stuff. 81 | * 82 | * @param string $datum 83 | * @param string $default 84 | * 85 | * @return string 86 | */ 87 | public function getThemeData(string $datum, string $default = ''): string 88 | { 89 | if (!isset($this->themeData)) { 90 | $this->themeData = wp_get_theme(); 91 | } 92 | 93 | // sadly, the get method of the WP_Theme object returns false when it 94 | // can't find the information requested. additionally sad, the theme likes 95 | // capitalized names for its information but the average human may not know 96 | // that. so, we'll try the exact $datum. if that's false, we try to 97 | // capitalize it and see what happens. 98 | 99 | $value = $this->themeData->get($datum); 100 | 101 | if ($value === false) { 102 | $value = $this->themeData->get(ucfirst($datum)); 103 | } 104 | 105 | return $value === false ? $default : (string) $value; 106 | } 107 | 108 | 109 | /** 110 | * register 111 | * 112 | * Registers either a script or a style for later use. 113 | * 114 | * @param string $file 115 | * @param array $dependencies 116 | * @param null $finalArg 117 | * @param string $url 118 | * @param string $dir 119 | * 120 | * @return string 121 | */ 122 | protected function register(string $file, array $dependencies = [], $finalArg = null, string $url = "", string $dir = ""): string 123 | { 124 | // the work of registering an asset is the same as enqueuing one except 125 | // for the function we call at the end. thus, we can call our enqueue 126 | // method but we pass the Boolean true flag as the final parameter that 127 | // will cause it to execute either wp_register_style or wp_register_script 128 | // instead of the similarly named enqueue functions. 129 | 130 | return $this->enqueue($file, $dependencies, $finalArg, $url, $dir, true); 131 | } 132 | 133 | /** 134 | * enqueue 135 | * 136 | * Adds a script or style to the DOM and returns the name by which 137 | * the file is now known to WordPress. This method is protected, things 138 | * from outside the scope of our theme shouldn't be messing with our 139 | * assets, so it doesn't need to be in our interface. 140 | * 141 | * @param string $file 142 | * @param array $dependencies 143 | * @param string|bool|null $finalArg 144 | * @param string $url 145 | * @param string $dir 146 | * @param bool $register 147 | * 148 | * @return string 149 | */ 150 | protected function enqueue(string $file, array $dependencies = [], $finalArg = null, string $url = "", string $dir = "", bool $register = false): string { 151 | // remote assets (e.g. Google fonts) may begin with an HTTP protocol 152 | // string. we'll remove that to force browsers to load remote assets using 153 | // the same protocol as the rest of the page. 154 | 155 | $file = preg_replace("/^https?:/", "", $file); 156 | if (substr($file, 0, 2) === "//") { 157 | 158 | // if our $file begins with // then it's remote. therefore, we'll pass 159 | // control over to the method below which specifically handles remote 160 | // assets differently than we handle local assets below. 161 | 162 | return $this->enqueueRemote($file, $dependencies, $finalArg); 163 | } 164 | 165 | $asset = pathinfo($file, PATHINFO_FILENAME); 166 | 167 | // now that we know what we're working with, we need to determine what 168 | // we're here to do. first: we see if this is a script or a style based 169 | // on the extension of our file. then, we determine our action based on 170 | // the state of the $register parameter and construct the function we call 171 | // below using that action and our file type. 172 | 173 | $isScript = pathinfo($file, PATHINFO_EXTENSION) === 'js'; 174 | $action = $register ? 'register' : 'enqueue'; 175 | $type = $isScript ? 'script' : 'style'; 176 | $function = sprintf('wp_%s_%s', $action, $type); 177 | 178 | // if either (or both) of our url or dir parameters is empty, we set it to 179 | // the stylesheets url or dir as appropriate. we also make sure that these 180 | // end in a slash. 181 | 182 | $url = trailingslashit(empty($url) ? $this->getStylesheetUrl() : $url); 183 | $dir = trailingslashit(empty($dir) ? $this->getStylesheetDir() : $dir); 184 | 185 | if (is_null($finalArg)) { 186 | 187 | // the final argument for our $function is either a Boolean or a string 188 | // for scripts and styles respectively. if it's null at the moment, 189 | // we'll default it to the following. otherwise, we assume the calling 190 | // scope knows what it's doing. 191 | 192 | $finalArg = $isScript ? true : "all"; 193 | } 194 | 195 | // and, now we can enqueue. we call our $function and pass it a bunch of 196 | // stuff. note that we specify the FQDN for the local asset by prefixing 197 | // the filename with the URL. we also use the last modified timestamp of 198 | // the file as our "version" which should force browser to clear their 199 | // cache of these assets when the file changes. 200 | 201 | $function($asset, ($url . $file), $dependencies, filemtime($dir . $file), $finalArg); 202 | return $asset; 203 | } 204 | 205 | /** 206 | * enqueueRemote 207 | * 208 | * Returns the name of the asset used by WordPress to manage queued 209 | * dependencies. 210 | * 211 | * @param string $file 212 | * @param array $dependencies 213 | * @param mixed|null $finalArg 214 | * 215 | * @return string 216 | */ 217 | private function enqueueRemote(string $file, array $dependencies, $finalArg = null): string 218 | { 219 | // enqueuing a remote asset is a little easier than the local stuff we 220 | // handled above. because it can be hard to impossible to accurately 221 | // identify the filename of a remote asset with pathinfo, we'll just hash 222 | // $file and use that as our asset's name. similarly, getting the 223 | // extension with pathinfo doesn't work well, so we'll just look for 224 | // the extension ourselves. 225 | 226 | $asset = md5($file); 227 | $isScript = strpos($file, '.js') !== false; 228 | $function = $isScript ? "wp_enqueue_script" : "wp_enqueue_style"; 229 | if (is_null($finalArg)) { 230 | 231 | // the final argument for our $function is either a Boolean or a string 232 | // for scripts and styles respectively. if it's null at the moment, 233 | // we'll default it to the following. otherwise, we assume the calling 234 | // scope knows what it's doing. 235 | 236 | $finalArg = $isScript ? true : "all"; 237 | } 238 | 239 | // and that's it. we can call our function passing it the values we've 240 | // identified. for local assets we use the last modified timestamp of the 241 | // file as a "version" but here we just use the year and month so that 242 | // browsers will update their caches periodically but not too often. 243 | 244 | $function($asset, $file, $dependencies, date('Ym'), $finalArg); 245 | return $asset; 246 | } 247 | 248 | /** 249 | * parentEnqueue 250 | * 251 | * Enqueues an asset from within a parent theme's folder. Throws an 252 | * exception if this is not a child theme. 253 | * 254 | * @param string $file 255 | * @param array $dependencies 256 | * @param null $finalArg 257 | * @param bool $register 258 | * 259 | * @return string 260 | * @throws HandlerException 261 | */ 262 | protected function enqueueParent(string $file, array $dependencies = [], $finalArg = null, bool $register = false): string 263 | { 264 | if (!is_child_theme()) { 265 | throw new HandlerException($this->getThemeData('name') . ' is not a child theme.', 266 | HandlerException::NOT_A_CHILD); 267 | } 268 | 269 | // now that we've confirmed this is a child theme, all we need to do is 270 | // call the enqueue method above and specify the URI and folder for its 271 | // parent, the template, so that we override the defaults in that method. 272 | 273 | return $this->enqueue($file, $dependencies, $finalArg, 274 | get_template_directory_uri(), get_template_directory(), $register); 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/Handlers/Themes/ThemeHandlerInterface.php: -------------------------------------------------------------------------------- 1 | getMessage(), $code, $exception); 37 | } 38 | 39 | /** 40 | * @param string $hook 41 | */ 42 | protected function setHook(string $hook): void 43 | { 44 | $this->hook = $hook; 45 | } 46 | 47 | /** 48 | * @param int $priority 49 | */ 50 | protected function setPriority(int $priority = 10): void 51 | { 52 | $this->priority = $priority; 53 | } 54 | 55 | /** 56 | * @param int $argumentCount 57 | * 58 | * @throws HookException 59 | */ 60 | protected function setArgumentCount(int $argumentCount = 1): void 61 | { 62 | if ($argumentCount < 0) { 63 | throw new HookException("Invalid argument count: $argumentCount.", HookException::INVALID_ARGUMENT_COUNT); 64 | } 65 | 66 | $this->argumentCount = $argumentCount; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Hooks/ClosureHook.php: -------------------------------------------------------------------------------- 1 | $hook, 40 | 'callback' => $callback, 41 | 'priority' => $priority, 42 | 'argumentCount' => $argumentCount, 43 | ] 44 | ); 45 | } catch (RepositoryException $e) { 46 | // to avoid the calling scopes needing to know about this 47 | // RepositoryException, we're going to convert it to a HookException 48 | // which is more specific to this context. 49 | 50 | throw $this->convertException($e, HookException::FAILURE_TO_CONSTRUCT); 51 | } 52 | } 53 | 54 | /** 55 | * setCallback 56 | * 57 | * Sets the callback property. 58 | * 59 | * @param Closure $callback 60 | * 61 | * @return void 62 | */ 63 | protected function setCallback(Closure $callback) 64 | { 65 | $this->callback = $callback; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Hooks/Collection/Factory/HookCollectionFactory.php: -------------------------------------------------------------------------------- 1 | key()); 67 | } 68 | 69 | /** 70 | * offsetGet 71 | * 72 | * Returns the value at the specified index within the collection. Note: 73 | * because we can't alter the method's signature, we can't type hint $index 74 | * here. So, we'll rely on the phpDocBlock to help our IDEs out instead. 75 | * 76 | * @param string $offset 77 | * 78 | * @return HookInterface|null 79 | */ 80 | public function offsetGet($offset): ?HookInterface 81 | { 82 | return parent::offsetGet($offset); 83 | } 84 | 85 | /** 86 | * offsetSet 87 | * 88 | * Adds the value to the collection at the specified index. Note: because 89 | * we can't alter the method's signature, we can't type hint the parameters 90 | * here. So, we'll rely on the phpDocBlock to help our IDEs out instead. 91 | * 92 | * @param string $offset 93 | * @param HookInterface $value 94 | * 95 | * @return void 96 | * @throws HookCollectionException 97 | */ 98 | public function offsetSet($offset, $value): void 99 | { 100 | if (!($value instanceof HookInterface)) { 101 | throw new HookCollectionException( 102 | 'Collected objects must implement HookInterface', 103 | HookCollectionException::NOT_A_HOOK 104 | ); 105 | } 106 | 107 | parent::offsetSet($offset, $value); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Hooks/Collection/HookCollectionException.php: -------------------------------------------------------------------------------- 1 | $hook, 38 | 'handler' => $handler, 39 | 'method' => $method, 40 | 'priority' => $priority, 41 | 'argumentCount' => $argumentCount, 42 | ] 43 | ); 44 | } catch (RepositoryException $e) { 45 | // to avoid the calling scopes needing to know about this 46 | // RepositoryException, we're going to convert it to a HookException 47 | // which is more specific to this context. 48 | 49 | throw $this->convertException($e, HookException::FAILURE_TO_CONSTRUCT); 50 | } 51 | } 52 | 53 | /** 54 | * setMethod 55 | * 56 | * Sets the method property. 57 | * 58 | * @param string $method 59 | * 60 | * @return void 61 | */ 62 | protected function setMethod(string $method): void 63 | { 64 | // we assume that the scope using this object has confirmed that this 65 | // method is a part of handler. that's a bit more work than we would 66 | // usually ask of a Repository anyway. 67 | 68 | $this->method = $method; 69 | } 70 | 71 | /** 72 | * setHandler 73 | * 74 | * Sets the handler property. 75 | * 76 | * @param HandlerInterface $handler 77 | * 78 | * @return void 79 | */ 80 | protected function setHandler(HandlerInterface $handler): void 81 | { 82 | $this->handler = $handler; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Repositories/AgentDefinition/AgentDefinition.php: -------------------------------------------------------------------------------- 1 | $agent, 35 | 'parameters' => $parameters, 36 | ] 37 | ); 38 | } 39 | 40 | /** 41 | * setAgent 42 | * 43 | * Sets the agent property after confirming that the $agent parameter is the 44 | * name of an object that is, in fact, an Agent. 45 | * 46 | * @param string $agent 47 | * 48 | * @return void 49 | * @throws AgentDefinitionException 50 | */ 51 | protected function setAgent(string $agent): void 52 | { 53 | // the string we receive here must be the name of an Agent. so, before we 54 | // set our property, we want to confirm that this requirement is met. 55 | // first, we check to see if $agent references an existent class. 56 | 57 | if (!class_exists($agent)) { 58 | throw new AgentDefinitionException( 59 | sprintf("Unknown agent: %s", $this->getObjectShortName($agent)), 60 | AgentDefinitionException::NOT_AN_AGENT 61 | ); 62 | } 63 | 64 | // now, we want to make sure that class implements the AgentInterface. 65 | 66 | $interfaces = class_implements($agent); 67 | if (!in_array(AgentInterface::class, $interfaces)) { 68 | throw new AgentDefinitionException( 69 | sprintf("%s is not an agent", $this->getObjectShortName($agent)), 70 | AgentDefinitionException::NOT_AN_AGENT 71 | ); 72 | } 73 | 74 | // finally, each Agent must extend one of the following: AbstractAgent, 75 | // AbstractPluginAgent, or AbstractThemeAgent. we'll loop through the 76 | // parents of $agent and see if we find one of them. if we do, we set our 77 | // property and return. otherwise, we'll go through the entire loop and 78 | // then throw a final exception below. 79 | 80 | $temp = $agent; 81 | while ($temp = get_parent_class($temp)) { 82 | if (preg_match("/Abstract(?:Theme|Plugin)?Agent/", $temp)) { 83 | $this->agent = $agent; 84 | return; 85 | } 86 | } 87 | 88 | throw new AgentDefinitionException( 89 | sprintf("%s is not an agent", $this->getObjectShortName($agent)), 90 | AgentDefinitionException::NOT_AN_AGENT 91 | ); 92 | } 93 | 94 | /** 95 | * getObjectShortName 96 | * 97 | * Given the fully namespaced name of an object, return it's short name, 98 | * i.e. it's class name without all the namespacing. 99 | * 100 | * @param string $objectFullName 101 | * 102 | * @return string 103 | */ 104 | private function getObjectShortName(string $objectFullName): string 105 | { 106 | $agentNameParts = explode("\\", $objectFullName); 107 | return array_pop($agentNameParts); 108 | } 109 | 110 | /** 111 | * setParameters 112 | * 113 | * Sets the parameters property. 114 | * 115 | * @param array $parameters 116 | * 117 | * @return void 118 | */ 119 | protected function setParameters(array $parameters): void 120 | { 121 | $this->parameters = $parameters; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Repositories/AgentDefinition/AgentDefinitionException.php: -------------------------------------------------------------------------------- 1 | default); 34 | $hasOptions = !empty($this->options); 35 | if ($hasDefault || $hasOptions) { 36 | 37 | // now that we know we have one or the other (or both), we need to see 38 | // if our default and options properties are valid. 39 | 40 | if (!$hasDefault && $hasOptions) { 41 | 42 | // if we have options, but the programmer didn't specify a default 43 | // value, we'll make the opinionated choice to use the first one as 44 | // that value. if they don't like it, then they should have told us 45 | // otherwise! 46 | 47 | $this->default = $this->options[0]; 48 | } elseif ($hasDefault && !$hasOptions) { 49 | 50 | // if we have a default value but no options, we could wonder as to 51 | // why they set things up this way, but we can at least prevent WP from 52 | // yelling about it by making our list of options an array of the 53 | // single value we do have. 54 | 55 | $this->options = [$this->default]; 56 | } elseif (!in_array($this->default, $this->options)) { 57 | 58 | // and here's the only real problem we can't solve here: if we have 59 | // both a default and options but the former is not contained in the 60 | // latter, then we just have to throw an exception and let a dev fix 61 | // it. 62 | 63 | throw new ArgumentException( 64 | 'Invalid default: ' . $this->default, 65 | ArgumentException::INVALID_DEFAULT 66 | ); 67 | } 68 | } 69 | } 70 | 71 | /** 72 | * setType 73 | * 74 | * Sets the type property. 75 | * 76 | * @param string $type 77 | * 78 | * @return void 79 | * @throws ArgumentException 80 | */ 81 | protected function setType(string $type): void 82 | { 83 | if (!in_array($type, ['positional', 'assoc', 'flag'])) { 84 | throw new ArgumentException( 85 | 'Invalid type: ' . $type, 86 | ArgumentException::INVALID_TYPE 87 | ); 88 | } 89 | 90 | $this->type = $type; 91 | } 92 | 93 | /** 94 | * setName 95 | * 96 | * Sets the name property. 97 | * 98 | * @param string $name 99 | * 100 | * @return void 101 | */ 102 | protected function setName(string $name): void 103 | { 104 | $this->name = $name; 105 | } 106 | 107 | /** 108 | * setDescription 109 | * 110 | * Sets the description property. 111 | * 112 | * @param string $description 113 | * 114 | * @return void 115 | */ 116 | protected function setDescription(string $description): void 117 | { 118 | $this->description = $description; 119 | } 120 | 121 | /** 122 | * setDefault 123 | * 124 | * Sets the default property. 125 | * 126 | * @param string $default 127 | * 128 | * @return void 129 | */ 130 | protected function setDefault(string $default): void 131 | { 132 | $this->default = $default; 133 | } 134 | 135 | /** 136 | * setOptions 137 | * 138 | * Sets the options property. 139 | * 140 | * @param array|null $options 141 | * 142 | * @return void 143 | */ 144 | protected function setOptions(?array $options): void 145 | { 146 | $this->options = $options; 147 | } 148 | 149 | /** 150 | * setRepeating 151 | * 152 | * Sets the repeating property. 153 | * 154 | * @param bool $repeating 155 | * 156 | * @return void 157 | */ 158 | protected function setRepeating(bool $repeating): void 159 | { 160 | $this->repeating = $repeating; 161 | } 162 | 163 | /** 164 | * setOptional 165 | * 166 | * Sets the optional property. 167 | * 168 | * @param bool $optional 169 | * 170 | * @return void 171 | */ 172 | protected function setOptional(bool $optional): void 173 | { 174 | $this->optional = $optional; 175 | } 176 | 177 | /** 178 | * offsetExists 179 | * 180 | * Returns true when $offset matches one of our properties. 181 | * 182 | * @param string $offset 183 | * 184 | * @return bool 185 | */ 186 | public function offsetExists($offset): bool 187 | { 188 | return property_exists($this, $offset) && isset($this->offset); 189 | } 190 | 191 | /** 192 | * offsetGet 193 | * 194 | * Returns the value of one of our properties as identified by $offset. 195 | * 196 | * @param string $offset 197 | * 198 | * @return mixed|null 199 | */ 200 | public function offsetGet($offset): mixed 201 | { 202 | return $this->offsetExists($offset) ? $this->$offset : null; 203 | } 204 | 205 | /** 206 | * offsetSet 207 | * 208 | * This method is required as per the ArrayAccess interface, but we don't 209 | * want to use it because it allows public access to our properties which is 210 | * against the "rules" for a repository. So, if someone tries to use it we 211 | * throw an exception. We also mark it final because we don't want anyone to 212 | * just override it. 213 | * 214 | * @param string $offset 215 | * @param mixed $value 216 | * 217 | * @return never 218 | * @throws ArgumentException 219 | */ 220 | final public function offsetSet($offset, mixed $value): never 221 | { 222 | throw new ArgumentException( 223 | 'Attempt to set ' . $offset . ' via ArrayAccess', 224 | ArgumentException::ACCESS_VIOLATION 225 | ); 226 | } 227 | 228 | /** 229 | * offsetUnset 230 | * 231 | * This method is required as per the ArrayAccess interface, but we don't 232 | * want to use it because it allows public access to our properties which is 233 | * against the "rules" for a repository. So, if someone tries to use it we 234 | * throw an exception. We also mark it final because we don't want anyone to 235 | * just override it. 236 | * 237 | * @param string $offset 238 | * 239 | * @return never 240 | * @throws ArgumentException 241 | */ 242 | public function offsetUnset($offset): never 243 | { 244 | throw new ArgumentException( 245 | 'Attempt to unset ' . $offset . ' via ArrayAccess', 246 | ArgumentException::ACCESS_VIOLATION 247 | ); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/Repositories/Arguments/ArgumentException.php: -------------------------------------------------------------------------------- 1 | $command, 39 | 'argumentCollection' => $argumentCollection ?? ArgumentCollection::class, 40 | 'parameters' => $parameters, 41 | ]); 42 | } 43 | 44 | /** 45 | * setCommand 46 | * 47 | * Sets the command property after confirming that our parameter names an 48 | * object that implements the CommandInterface and extends the 49 | * AbstractCommand. 50 | * 51 | * @param string $command 52 | * 53 | * @throws CommandDefinitionException 54 | */ 55 | public function setCommand(string $command): void 56 | { 57 | // first, the command named by our parameter must be a class that exists. 58 | // then, we'll want to see if it implements the CommandInterface interface. 59 | 60 | if (!class_exists($command)) { 61 | throw new CommandDefinitionException( 62 | 'Unknown command: ' . $this->getShortName($command), 63 | CommandDefinitionException::UNKNOWN_COMMAND 64 | ); 65 | } 66 | 67 | $interfaces = class_implements($command); 68 | if (!in_array(CommandInterface::class, $interfaces)) { 69 | throw new CommandDefinitionException( 70 | $this->getShortName($command) . ' is not a Command', 71 | CommandDefinitionException::NOT_A_COMMAND 72 | ); 73 | } 74 | 75 | // if both of those criteria are met, the next step is to ensure that our 76 | // command extends the AbstractCommand class. if so, we're good to go. 77 | 78 | $temp = $command; 79 | while ($temp = get_parent_class($temp)) { 80 | if (strpos($temp, 'AbstractCommand') !== false) { 81 | $this->command = $command; 82 | 83 | // by returning here, we avoid throwing the exception below. 84 | 85 | return; 86 | } 87 | } 88 | 89 | // if we didn't return within the while loop above, then while the class 90 | // exists and implements our interface, it doesn't extend the abstract 91 | // command class. that means we can't use it either and we'll throw one 92 | // more exception. 93 | 94 | throw new CommandDefinitionException( 95 | $this->getShortName($command) . ' must extend AbstractCommand', 96 | CommandDefinitionException::NOT_A_COMMAND 97 | ); 98 | } 99 | 100 | /** 101 | * getCommandShortName 102 | * 103 | * Given the fully namespaced command name, returns just the CommandInterface 104 | * object's name that's at the end of it. 105 | * 106 | * @param string $className 107 | * 108 | * @return string 109 | */ 110 | protected function getShortName(string $className): string 111 | { 112 | $classNameParts = explode('\\', $className); 113 | return array_pop($classNameParts); 114 | } 115 | 116 | /** 117 | * setArgumentCollection 118 | * 119 | * Sets the argument collection property after confirming that our parameter 120 | * names an object that implements the ArgumentCollectionInterface. 121 | * 122 | * @param string $argumentCollection 123 | * 124 | * @return void 125 | * @throws CommandDefinitionException 126 | */ 127 | protected function setArgumentCollection(string $argumentCollection): void 128 | { 129 | // first, the command named by our parameter must be a class that exists. 130 | // then, we'll want to see if it implements the CommandInterface interface. 131 | // but, unlike our commands, there's no abstract object that we must 132 | // extend, so only those two checks happen here. 133 | 134 | if (!class_exists($argumentCollection)) { 135 | throw new CommandDefinitionException( 136 | 'Unknown argument collection: ' . $this->getShortName($argumentCollection), 137 | CommandDefinitionException::UNKNOWN_ARGUMENT_COLLECTION 138 | ); 139 | } 140 | 141 | $interfaces = class_implements($argumentCollection); 142 | if (!in_array(ArgumentCollectionInterface::class, $interfaces)) { 143 | throw new CommandDefinitionException( 144 | $this->getShortName($argumentCollection) . ' is not an ArgumentCollection', 145 | CommandDefinitionException::NOT_AN_ARGUMENT_COLLECTION 146 | ); 147 | } 148 | 149 | $this->argumentCollection = $argumentCollection; 150 | } 151 | 152 | /** 153 | * setParameters 154 | * 155 | * Sets the parameters property. 156 | * 157 | * @param array $parameters 158 | * 159 | * @return void 160 | */ 161 | protected function setParameters(array $parameters): void 162 | { 163 | $this->parameters = $parameters; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/Repositories/CommandDefinition/CommandDefinitionException.php: -------------------------------------------------------------------------------- 1 | handler = $handler; 66 | 67 | // our parent constructor will start to set properties of this object 68 | // so we need to call it after we set our handler above. this is 69 | // because we use the handler in the setCallable() method below. my 70 | // usual style is to call the parent constructor first and then do 71 | // my work for this object, but that won't work in this case. 72 | 73 | parent::__construct($data); 74 | } 75 | 76 | /** 77 | * getHiddenPropertyNames 78 | * 79 | * Ensures that the handler property is considered "hidden" within the 80 | * parent::__get() method. 81 | * 82 | * @return array 83 | */ 84 | protected function getHiddenPropertyNames(): array 85 | { 86 | return ["handler"]; 87 | } 88 | 89 | /** 90 | * getCustomPropertyDefaults 91 | * 92 | * Intended as a way to provide for functional defaults (e.g. the current 93 | * date), extensions can override this function to return an array of 94 | * default values for properties. that array should be indexed by property 95 | * names. 96 | * 97 | * @return array 98 | */ 99 | protected function getCustomPropertyDefaults(): array 100 | { 101 | return []; 102 | } 103 | 104 | /** 105 | * getRequiredProperties 106 | * 107 | * Returns an array of property names that must be non-empty after 108 | * construction. 109 | * 110 | * @return array 111 | */ 112 | protected function getRequiredProperties(): array 113 | { 114 | return ['pageTitle', 'menuTitle', 'menuSlug', 'capability', 'method']; 115 | } 116 | 117 | 118 | /** 119 | * toArray 120 | * 121 | * To absolutely guarantee that we return our properties in the order 122 | * in which they're declared above, we're going to override the default 123 | * toArray() method of our parent class and institute this one instead. 124 | * 125 | * @param string $format 126 | * 127 | * @return array 128 | */ 129 | public function toArray(string $format = ARRAY_N): array 130 | { 131 | // we want to use the WP_ARGUMENT_ORDER constant to be sure that we 132 | // return our properties in that order. this is to ensure 133 | // compatibility with the WP core add_menu_item and add_submenu_item 134 | // functions. 135 | 136 | $properties = []; 137 | foreach (static::WP_ARGUMENT_ORDER as $property) { 138 | $properties[$property] = $this->{$property}; 139 | } 140 | 141 | return $format !== ARRAY_A 142 | ? array_values($properties) 143 | : $properties; 144 | } 145 | 146 | /** 147 | * getParentSlug 148 | * 149 | * Returns an empty string because only the SubmenuItem class, an extension 150 | * of this one, has a parent slug. 151 | * 152 | * @return string 153 | */ 154 | public function getParentSlug(): string 155 | { 156 | return ""; 157 | } 158 | 159 | /** 160 | * isComplete 161 | * 162 | * Returns true if this item is complete and ready to be used within the 163 | * WordPress ecosystem. 164 | * 165 | * @return bool 166 | */ 167 | public function isComplete(): bool 168 | { 169 | // for a menu item to be complete, the properties listed in the 170 | // WP_ARGUMENT_ORDER constant must not be empty unless they're also 171 | // listed in the OPTIONAL_ARGUMENTS constant. we'll get the items in 172 | // the former that aren't in the latter, loop over them, and return 173 | // false if we find an empty one. 174 | 175 | $properties = array_diff(static::WP_ARGUMENT_ORDER, static::OPTIONAL_ARGUMENTS); 176 | 177 | foreach ($properties as $property) { 178 | if (empty($this->{$property})) { 179 | return false; 180 | } 181 | } 182 | 183 | return true; 184 | } 185 | 186 | /** 187 | * setPageTitle 188 | * 189 | * Sets the page title property. Also sets the menu title and slug 190 | * properties so that we can use a shortened argument list within our 191 | * Handlers than what is generally required by add_menu_page(). 192 | * 193 | * @param string $pageTitle 194 | * 195 | * @return void 196 | */ 197 | public function setPageTitle(string $pageTitle): void 198 | { 199 | $this->pageTitle = $pageTitle; 200 | 201 | // to homogenize the page title, menu title, and menu slug, if the latter 202 | // two have not yet been set, we'll set them here. but, if they're not 203 | // empty, we assume that the scope using this object knows what it's doing 204 | // and leave them alone. 205 | 206 | if (empty($this->menuTitle)) { 207 | $this->setMenuTitle($pageTitle); 208 | } 209 | 210 | if (empty($this->menuSlug)) { 211 | 212 | // for our menu slug, we replace all adjacent sets of whitespace 213 | // non-word characters, and underscores to a dash and lowercase the 214 | // entire string. then, we just make sure to remove a dash at the end 215 | // of the string more for aesthetics than anything else. 216 | 217 | $menuSlug = preg_replace("/[\s\W_]+/", "-", strtolower($pageTitle)); 218 | $menuSlug = preg_replace("/-$/", "", $menuSlug); 219 | $this->setMenuSlug($menuSlug); 220 | } 221 | } 222 | 223 | /** 224 | * setMenuTitle 225 | * 226 | * Sets the menu title property. 227 | * 228 | * @param string $menuTitle 229 | * 230 | * @return void 231 | */ 232 | public function setMenuTitle(string $menuTitle): void 233 | { 234 | $this->menuTitle = $menuTitle; 235 | } 236 | 237 | /** 238 | * setMenuSlug 239 | * 240 | * Sets the menu slug property. 241 | * 242 | * @param string $menuSlug 243 | * 244 | * @return void 245 | */ 246 | public function setMenuSlug(string $menuSlug): void 247 | { 248 | $this->menuSlug = $menuSlug; 249 | } 250 | 251 | /** 252 | * setCapability 253 | * 254 | * Sets the capability property. 255 | * 256 | * @param string $capability 257 | * 258 | * @return void 259 | */ 260 | public function setCapability(string $capability): void 261 | { 262 | $this->capability = $capability; 263 | } 264 | 265 | /** 266 | * setMethod 267 | * 268 | * Sets the method and callable properties. 269 | * 270 | * @param string $method 271 | * 272 | * @return void 273 | */ 274 | public function setMethod(string $method): void 275 | { 276 | $this->setCallable($this->handler, $method); 277 | $this->method = $method; 278 | } 279 | 280 | /** 281 | * setCallable 282 | * 283 | * Sets the callable property. 284 | * 285 | * @param PluginHandlerInterface $object 286 | * @param string $method 287 | * 288 | * @return void 289 | */ 290 | public function setCallable(PluginHandlerInterface $object, string $method): void 291 | { 292 | $this->callable = [$object, $method]; 293 | } 294 | 295 | /** 296 | * setIconUrl 297 | * 298 | * Sets the icon URL property. 299 | * 300 | * @param string $iconUrl 301 | * 302 | * @return void 303 | */ 304 | public function setIconUrl(string $iconUrl): void 305 | { 306 | $this->iconUrl = $iconUrl; 307 | } 308 | 309 | /** 310 | * setPosition 311 | * 312 | * Sets the position property which must be a positive number. 313 | * 314 | * @param int $position 315 | * 316 | * @return void 317 | */ 318 | public function setPosition(int $position): void 319 | { 320 | // if we don't get a positive number, then we'll stick to our default 321 | // value of 26 which puts this item after the Comments item in the 322 | // upper portion of the Dashboard menu. 323 | 324 | $this->position = $position > 0 ? $position : 26; 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /src/Repositories/MenuItems/MenuItemException.php: -------------------------------------------------------------------------------- 1 | parentSlug = $parentSlug; 71 | } 72 | 73 | /** 74 | * getParentSlug 75 | * 76 | * Returns the value of the parent slug property. 77 | * 78 | * @return string 79 | */ 80 | public function getParentSlug(): string 81 | { 82 | return $this->parentSlug; 83 | } 84 | 85 | /** 86 | * setIconUrl 87 | * 88 | * Submenu items don't have icons, so this method simply throws an 89 | * exception. 90 | * 91 | * @param string $iconUrl 92 | * 93 | * @throws MenuItemException 94 | */ 95 | public function setIconUrl(string $iconUrl): void 96 | { 97 | throw new MenuItemException( 98 | "Submenu items don't have icons.", 99 | MenuItemException::ATTEMPT_TO_SET_SUBMENU_ICON 100 | ); 101 | } 102 | 103 | /** 104 | * setPosition 105 | * 106 | * Submenu items don't have positions, so this method simply throws an 107 | * exception. 108 | * 109 | * @param int $position 110 | * 111 | * @throws MenuItemException 112 | */ 113 | public function setPosition(int $position): void 114 | { 115 | throw new MenuItemException( 116 | "Submenu items don't have positions.", 117 | MenuItemException::ATTEMPT_TO_SET_SUBMENU_POSITION 118 | ); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Repositories/PostValidity.php: -------------------------------------------------------------------------------- 1 | sizeof($data) === 0, 33 | 'problems' => $data, 34 | ]; 35 | } 36 | 37 | // otherwise, if we did have a valid index, we assume that $data is prepped 38 | // and ready by the constructing scope to construct this repository. if it 39 | // isn't we'll likely end up with some sort of error anyway. 40 | 41 | parent::__construct($data); 42 | } 43 | 44 | /** 45 | * setValid 46 | * 47 | * Sets the success property. 48 | * 49 | * @param bool $valid 50 | * 51 | * @return void 52 | */ 53 | protected function setValid(bool $valid): void 54 | { 55 | $this->valid = $valid; 56 | } 57 | 58 | /** 59 | * setProblems 60 | * 61 | * Sets the problems property. 62 | * 63 | * @param array $problems 64 | * 65 | * @return void 66 | */ 67 | protected function setProblems(array $problems): void 68 | { 69 | $this->problems = $problems; 70 | } 71 | 72 | /** 73 | * setData 74 | * 75 | * Sets the data property. 76 | * 77 | * @param array $data 78 | * 79 | * @return void 80 | */ 81 | protected function setData(array $data): void 82 | { 83 | $this->data = $data; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Traits/ActionAndNonceTrait.php: -------------------------------------------------------------------------------- 1 | getAction($action)); 33 | } 34 | 35 | /** 36 | * getNonceName 37 | * 38 | * Given the name of an action, gets a unique name for a nonce based on that 39 | * action. Lacking an action, returns one based on the default action 40 | * returned by getDefaultAction below. 41 | * 42 | * @param string|null $action 43 | * 44 | * @return string 45 | */ 46 | protected function getNonceName(?string $action = null): string 47 | { 48 | return $this->getAction($action) . '-nonce'; 49 | } 50 | 51 | /** 52 | * getAction 53 | * 54 | * Returns a string naming the action an on screen form is used to perform. 55 | * Typically, this is then used to link a form's submission to a method of 56 | * the object using this trait to process a visitor's work. 57 | * 58 | * @param string|null $action 59 | * 60 | * @return string 61 | */ 62 | protected function getAction(?string $action = null): string 63 | { 64 | try { 65 | 66 | // dash typically adds a class constant named SLUG to their objects 67 | // which is used for all sorts of things. one thing we an use it for 68 | // is a prefix for our actions if it exists. 69 | 70 | $slugConstant = new ReflectionClassConstant($this, 'SLUG'); 71 | $prefix = $slugConstant->getValue(); 72 | } catch (Exception $e) { 73 | 74 | // if the constant doesn't exist, we'll use a kebab case version of 75 | // our class name or that class's handler (if it exists). then, we see 76 | // if the class we found has a visible SLUG constant and, if so, we use 77 | // it. if not, we'll just use the classname to produce a classname. 78 | 79 | $namespacedClassName = property_exists($this, 'handler') 80 | ? get_class($this->handler) 81 | : get_class($this); 82 | 83 | $prefix = !defined($namespacedClassName . '::SLUG') 84 | ? $this->getActionPrefixFromClassName($namespacedClassName) 85 | : $namespacedClassName::SLUG; 86 | } 87 | 88 | return sprintf('%s-%s', $prefix, $action ?? $this->getDefaultAction()); 89 | } 90 | 91 | /** 92 | * getActionPrefixFromClassName 93 | * 94 | * Extracted from the prior method and made into its own so that it can be 95 | * overridden by those who use this trait as needed, this method returns a 96 | * prefix for our actions based on a given namespaced classname. 97 | * 98 | * @param string $className 99 | * 100 | * @return string 101 | */ 102 | protected function getActionPrefixFromClassName(string $className): string 103 | { 104 | // $className is expected to be a fully namespaced class name. so, we'll 105 | // explode it into it's parts, grab the last one, and then, since the PHP 106 | // styles suggest that class names be in StudlyCaps, we'll convert those to 107 | // kebab case. 108 | 109 | $class = array_reverse(explode('\\', $className))[0]; 110 | return $this->pascalToKebabCase($class); 111 | } 112 | 113 | /** 114 | * getDefaultAction 115 | * 116 | * Returns the name of the default action for our getAction method. 117 | * Typically, this is "save," but users of this Trait can override this as 118 | * necessary. 119 | * 120 | * @return string 121 | */ 122 | protected function getDefaultAction(): string 123 | { 124 | return 'save'; 125 | } 126 | 127 | /** 128 | * userCan 129 | * 130 | * The newer, preferred way to check a user's action and, when available, 131 | * nonce in order to determine that they're authorized to perform a specific 132 | * action. It replaces both isValidAction and isValidActionAndNonce. 133 | * 134 | * @param string|null $action 135 | * 136 | * @return bool 137 | */ 138 | protected function userCan(?string $action = null): bool 139 | { 140 | $action ??= $this->getDefaultAction(); 141 | if (!current_user_can($this->getCapabilityForAction($action))) { 142 | 143 | // the default title and message can be found in the core 144 | // wp-admin/options.php file. we add our own filters to make it possible 145 | // to contextualize either or both of them and then we call wp_die. the 146 | // core process also dies, so we'll just follow their lead. 147 | 148 | $title = apply_filters('wp-handler-invalid-action-title', 'You need a higher level of permission.'); 149 | $message = apply_filters('wp-handler-invalid-action-message', 'Sorry, you are not allowed to manage options for this site.'); 150 | wp_die('

' . $title . '

' . $message . '

', 403); 151 | } 152 | 153 | // now, if there is a nonce in the request that has name that matches our 154 | // action, we want to test it. if a nonce isn't found, we check the value 155 | // of our requireNonces flag; if it's set then the lack of a nonce is an 156 | // error. if we found a nonce, we verify it. if either of these criteria 157 | // are not met, we'll call the core function that handles nonce problems. 158 | 159 | $nonce = $this->getNonceName($action); 160 | $nonceFound = isset($_REQUEST[$nonce]); 161 | $nonceNeeded = !$nonceFound && $this->requireNonces; 162 | $nonceInvalid = $nonceFound && !wp_verify_nonce($_REQUEST[$nonce], 163 | $this->getAction($action)); 164 | 165 | if ($nonceNeeded || $nonceInvalid) { 166 | 167 | // if our action and nonce don't match, then we can call the core 168 | // wp_nonce_ays (are you sure) function to display an error message. 169 | // this function the calls wp_die internally, so if we're in here, the 170 | // execution of this request halts and a response is sent to the 171 | // visitor. 172 | 173 | wp_nonce_ays($action); 174 | } 175 | 176 | // since both failure cases above call wp_die, if we're here, then we can 177 | // simply return true. any other situation has already been handled. 178 | 179 | return true; 180 | } 181 | 182 | 183 | /** 184 | * isValidActionAndNonce 185 | * 186 | * Returns true if the action and nonce contained within our $_REQUEST are 187 | * valid. on invalid data, wp_die is called. 188 | * 189 | * @param string|null $action 190 | * 191 | * @return bool 192 | */ 193 | protected function isValidActionAndNonce(?string $action = null): bool 194 | { 195 | trigger_error( 196 | 'The isValidActionAndNonce method is deprecated; use userCan instead.', 197 | E_USER_DEPRECATED 198 | ); 199 | 200 | return $this->userCan($action); 201 | } 202 | 203 | /** 204 | * isValidAction 205 | * 206 | * Uses other methods of this trait to confirm the validity of an action and, 207 | * when necessary, a nonce contained within the $_REQUEST. method always 208 | * returns true because wp_die is called what either are invalid. 209 | * 210 | * @param string|null $action 211 | * @param bool $checkNonce 212 | * 213 | * @return bool 214 | * @noinspection PhpUnusedParameterInspection 215 | */ 216 | protected function isValidAction(?string $action = null, bool $checkNonce = false): bool 217 | { 218 | trigger_error( 219 | 'The isValidAction method is deprecated; use userCan instead.', 220 | E_USER_DEPRECATED 221 | ); 222 | 223 | return $this->userCan($action); 224 | } 225 | 226 | /** 227 | * getCapabilityForAction 228 | * 229 | * Given the name of an action this visitor is attempting to perform, 230 | * returns the WP capability necessary to do so. By default, we return 231 | * manage_options for all actions, but users of this Trait can override this 232 | * behavior for more specificity. 233 | * 234 | * @param string $action 235 | * 236 | * @return string 237 | * @noinspection PhpUnusedParameterInspection 238 | */ 239 | protected function getCapabilityForAction(string $action): string 240 | { 241 | return 'manage_options'; 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/Traits/CaseChangingTrait.php: -------------------------------------------------------------------------------- 1 | commands = $commands ?? new CommandCollection(); 23 | } 24 | 25 | /** 26 | * registerCommand 27 | * 28 | * Registers the "top-level" command for the CLI; i.e. the information that 29 | * appears when you execute `wp help` on the command line. 30 | * 31 | * @param string $name 32 | * @param string $description 33 | * @param string|null $className 34 | * 35 | * @return void 36 | * @throws Exception 37 | */ 38 | protected function registerCommand(string $name, string $description, ?string $className = null): void 39 | { 40 | // at this time, WP_CLI v2.5 requires that an object name be passed to the 41 | // add_command method if you're going to use subcommands. any other means 42 | // of registering a top-level command results in a fatal error when you add 43 | // a subcommand. since our top-level commands don't need to do anything, 44 | // i.e. they exist only to print the `wp help` information, we can use a 45 | // stdClass here just to make the rest of the system work better. but, we 46 | // can specify a different object with the third parameter above if needed. 47 | 48 | $className = $className ?? stdClass::class; 49 | WP_CLI::add_command($name, $className, ['shortdesc' => $description]); 50 | } 51 | 52 | /** 53 | * registerCommand 54 | * 55 | * Adds a subcommand agent to our collection of them. 56 | * 57 | * @param CommandInterface $command 58 | * 59 | * @return void 60 | */ 61 | protected function registerSubcommand(CommandInterface $command): void 62 | { 63 | if (!isset($this->commands)) { 64 | 65 | // if we try to register a command without setting up our collection of 66 | // them, then we'll set it up using the default CommandCollection object 67 | // if someone wants to use something else, they'll have to have told us 68 | // so before now. 69 | 70 | $this->setCommandCollection(); 71 | } 72 | 73 | $this->commands[$command->slug] = $command; 74 | } 75 | 76 | /** 77 | * initializeCommands 78 | * 79 | * As a handler, this object already has an initialize method that its 80 | * extensions must implement. Similar to the initializeAgents method, this 81 | * one is intended to add our commands to the WP CLI and should be called 82 | * from the aforementioned initialize method. 83 | * 84 | * @throws HandlerException 85 | */ 86 | protected function initializeCommands(): void 87 | { 88 | foreach ($this->commands as $command) { 89 | /** @var CommandInterface $command */ 90 | 91 | try { 92 | $command->initialize(); 93 | WP_CLI::add_command( 94 | "$command->namespace $command->name", 95 | $command->getCallable(), 96 | $command->getDescription() 97 | ); 98 | } catch (Exception $e) { 99 | throw new HandlerException($e->getMessage(), $e->getCode(), $e); 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Traits/FormattedDateTimeTrait.php: -------------------------------------------------------------------------------- 1 | getTimestamp($timestamp, $timezone) 28 | ); 29 | } 30 | 31 | /** 32 | * getTimestamp 33 | * 34 | * Returns either the current timestamp or the specified one in either the 35 | * timezone specified in the WP settings or the one specified here. 36 | * 37 | * @param int|null $timestamp 38 | * @param string|null $timezone 39 | * 40 | * @return int 41 | */ 42 | private function getTimestamp(?int $timestamp, ?string $timezone): int 43 | { 44 | return ($timestamp ?? time()) + $this->convertTimezoneToOffset($timezone); 45 | } 46 | 47 | /** 48 | * convertTimezoneToOffset 49 | * 50 | * Given a timezone string, uses it or the WP settings to determine the 51 | * offset from UTC for that timezone in seconds. 52 | * 53 | * @param string|null $timezone 54 | * 55 | * @return int 56 | */ 57 | private function convertTimezoneToOffset(?string $timezone): int 58 | { 59 | // if the timezone parameter is null, then we want to get the timezone 60 | // string out of the WP settings. if that, too, is empty, we'll see if 61 | // the site admins have specified a GMT offset. if not even that is 62 | // specified, then we'll default to the PHP default. regardless, when 63 | // we're done here, we want to return the UTC offset in seconds. 64 | 65 | $offset = null; 66 | 67 | if ($timezone === null) { 68 | $timezone = get_option('timezone_string'); 69 | if (empty($timezone)) { 70 | $offset = get_option('gmt_offset'); 71 | if ($offset === '') { 72 | $timezone = date_default_timezone_get(); 73 | } 74 | } 75 | } 76 | 77 | // if $offset is now not null, we can skip converting a timezone string 78 | // into that offset; it must have been changed in the blocks above. 79 | // otherwise, we work with $timezone to produce a new $offset. 80 | 81 | if ($offset !== null) { 82 | 83 | // the only way that $offset is not null is if it was set in the 84 | // if-blocks at the start of this method. if that's the case, it's 85 | // in hours because that's the unit WP specifies. therefore, we 86 | // convert it to seconds to match the "Z" format used below. 87 | 88 | $offset *= 3600; 89 | } else { 90 | try { 91 | $time = new DateTime('now', new DateTimeZone($timezone)); 92 | $offset = $time->format('Z'); 93 | } catch (Exception $e) { 94 | 95 | // an exception is thrown by the DateTime constructor when the 96 | // format string cannot be parsed. in this case, the 'now' 97 | // string should always be parsable, so we shouldn't end up 98 | // here. but, if we do, all we can do is trigger an error and 99 | // hope a human can follow up. 100 | 101 | trigger_error('Unable to construct DateTime object', E_USER_ERROR); 102 | } 103 | } 104 | 105 | return $offset; 106 | } 107 | 108 | /** 109 | * getFormattedTime 110 | * 111 | * Returns either the current time in the WordPress time format or the 112 | * timestamp therein. 113 | * 114 | * @param int|null $timestamp 115 | * @param string|null $timezone 116 | * @param string|null $format 117 | * 118 | * @return string 119 | */ 120 | protected function getFormattedTime(?int $timestamp = null, ?string $timezone = null, ?string $format = null): string 121 | { 122 | return date($format ?? get_option('time_format'), $this->getTimestamp($timestamp, $timezone)); 123 | } 124 | 125 | /** 126 | * getFormattedDateTime 127 | * 128 | * Uses the prior to methods to return the current date and time in the 129 | * WordPress format for each or the timestamp therein. 130 | * 131 | * @param int|null $timestamp 132 | * @param string|null $timezone 133 | * 134 | * @return string 135 | */ 136 | protected function getFormattedDateTime(?int $timestamp = null, ?string $timezone = null): string 137 | { 138 | return sprintf( 139 | '%s at %s', 140 | $this->getFormattedDate($timestamp, $timezone), 141 | $this->getFormattedTime($timestamp, $timezone) 142 | ); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Traits/NetworkOptionsManagementTrait.php: -------------------------------------------------------------------------------- 1 | isOptionCached($option)) { 44 | return $this->getCachedOption($option); 45 | } 46 | 47 | // it's hard to make a trait know about the methods that are available 48 | // in the classes in which it might be used. so, we won't use the 49 | // isDebug method here, we'll just execute the same command that it 50 | // does. 51 | 52 | if ($this->isOptionValid($option, defined('WP_DEBUG') && WP_DEBUG)) { 53 | $fullOptionName = $this->getFullOptionName($option); 54 | $value = $this->retrieveOption($fullOptionName, $default); 55 | 56 | // as long as we can transform and our value isn't empty, we'll 57 | // pass it through our transformer. we skip empty values to avoid 58 | // getting tripped up by transformer method parameter type hints. 59 | 60 | $value = $this->canTransformOptions($transform) && !empty($value) 61 | ? $this->transformer->transformFromStorage($option, $value) 62 | : $value; 63 | } 64 | 65 | // here, if we didn't set $value in our if-block, we'll do so here with 66 | // the null coalescing operator. then, if we're using the cache we 67 | // want to remember it for next time. 68 | 69 | $value = $value ?? $default; 70 | $this->maybeCacheOption($option, $value); 71 | return $value; 72 | } 73 | 74 | /** 75 | * isOptionCached 76 | * 77 | * Given the name of an option, determines if a value for it exists in 78 | * the cache. 79 | * 80 | * @param string $option 81 | * 82 | * @return bool 83 | */ 84 | protected function isOptionCached(string $option): bool 85 | { 86 | return $this->useOptionsCache && isset($this->optionsCache[$option]); 87 | } 88 | 89 | /** 90 | * getCachedOption 91 | * 92 | * Given the name of the option, returns the value for it in the cache. 93 | * Assumes that isOptionCached() has been previously called but uses the 94 | * null coalescing operator to return null if a mistake was made. 95 | * 96 | * @param string $option 97 | * 98 | * @return mixed 99 | */ 100 | protected function getCachedOption(string $option) 101 | { 102 | return $this->optionsCache[$option] ?? null; 103 | } 104 | 105 | /** 106 | * getFullOptionName 107 | * 108 | * Handles the prefixing of our $option parameter so that other methods of 109 | * this trait don't have to. 110 | * 111 | * @param string $option 112 | * 113 | * @return string 114 | */ 115 | protected function getFullOptionName(string $option): string 116 | { 117 | $option = trim($option); 118 | 119 | // if the first character of our option's name is an underscore, we move it 120 | // to the beginning of the return value. options aren't hidden in the same 121 | // way as post meta, but this allows us to mark an option with a leading 122 | // prefix if we need to for some reason. 123 | 124 | return substr($option, 0, 1) === '_' 125 | ? '_' . $this->getOptionNamePrefix() . substr($option, 1) 126 | : $this->getOptionNamePrefix() . $option; 127 | } 128 | 129 | /** 130 | * isOptionValid 131 | * 132 | * Returns true if the option we're working with is valid with respect to 133 | * this handler's sphere of influence. if it's not, it'll either return 134 | * false or throw a HandlerException based on the value of $throw. 135 | * 136 | * @param string $option 137 | * @param bool $throw 138 | * 139 | * @return bool 140 | * @throws HandlerException 141 | */ 142 | protected function isOptionValid(string $option, bool $throw = true): bool 143 | { 144 | $isValid = in_array($option, $this->getValidOptionNames()); 145 | 146 | if (!$isValid && $throw) { 147 | throw new HandlerException( 148 | 'Unknown option:' . $option, 149 | HandlerException::UNKNOWN_OPTION 150 | ); 151 | } 152 | 153 | return $isValid; 154 | } 155 | 156 | /** 157 | * getValidOptionNames 158 | * 159 | * The full set of options names include the custom options managed by the 160 | * handler or agent using this trait and the name of the options snapshot 161 | * identified herein. This method just makes sure to add the latter to the 162 | * former. 163 | * 164 | * @return array 165 | */ 166 | protected function getValidOptionNames(): array 167 | { 168 | $options = $this->getOptionNames(); 169 | $options[] = $this->getOptionSnapshotName(); 170 | return $options; 171 | } 172 | 173 | /** 174 | * getOptionNames 175 | * 176 | * Returns an array of valid option names for use within the isOptionValid 177 | * method. 178 | * 179 | * @return array 180 | */ 181 | abstract protected function getOptionNames(): array; 182 | 183 | /** 184 | * getOptionNamePrefix 185 | * 186 | * Returns the prefix that that is used to differentiate the options for 187 | * this handler's sphere of influence from others. By default, we return 188 | * an empty string, but we assume that this will likely get overridden. 189 | * Public in case an agent needs to ask their handler what prefix to use. 190 | * 191 | * @return string 192 | */ 193 | public function getOptionNamePrefix(): string 194 | { 195 | return ''; 196 | } 197 | 198 | /** 199 | * retrieveOption 200 | * 201 | * Retrieves an option from the database. Separated from its surrounding 202 | * scope so we can override this, e.g. for network options. 203 | * 204 | * @param string $option 205 | * @param mixed $default 206 | * 207 | * @return mixed 208 | */ 209 | protected function retrieveOption(string $option, $default = '') 210 | { 211 | return get_option($option, $default); 212 | } 213 | 214 | /** 215 | * canTransformOptions 216 | * 217 | * Returns true if it we both desire to transform an option value and if we 218 | * can do so, i.e. if we have an option transformer. 219 | * 220 | * @param bool $transform 221 | * 222 | * @return bool 223 | */ 224 | protected function canTransformOptions(bool $transform): bool 225 | { 226 | return $transform 227 | && property_exists($this, "transformer") 228 | && $this->transformer instanceof StorageTransformerInterface; 229 | } 230 | 231 | /** 232 | * maybeCacheOption 233 | * 234 | * If we're using the cache, we add this option/value pair to it. 235 | * 236 | * @param string $option 237 | * @param mixed $value 238 | * 239 | * @return void 240 | */ 241 | protected function maybeCacheOption(string $option, $value): void 242 | { 243 | if ($this->useOptionsCache) { 244 | $this->optionsCache[$option] = $value; 245 | } 246 | } 247 | 248 | /** 249 | * getAllOptions 250 | * 251 | * Loops over the array of option names and returns their values as an 252 | * array transforming them as necessary. 253 | * 254 | * @param bool $transform 255 | * 256 | * @return array 257 | * @throws HandlerException 258 | * @throws TransformerException 259 | */ 260 | public function getAllOptions(bool $transform = true): array 261 | { 262 | foreach ($this->getOptionNames() as $optionName) { 263 | // we don't have to worry about accessing the cache here because, 264 | // if we're using it, the getOption method will use it internally. 265 | 266 | $options[$optionName] = $this->getOption($optionName, '', $transform); 267 | } 268 | 269 | // just in case someone calls this function on a handler that doesn't 270 | // have any options to retrieve, we'll need to use the null coalescing 271 | // operator to ensure that we return an empty array in the event that 272 | // $options is not defined in the above loop. 273 | 274 | return $options ?? []; 275 | } 276 | 277 | /** 278 | * getOptionsSnapshot 279 | * 280 | * Sometimes is important to be sure we use the minimum number of database 281 | * queries. This will pull an array from the database in a single query 282 | * and then transform it and return that array. It'll only have data to 283 | * provide if updateOptionsSnapshot has been used to store these options 284 | * in the database in this capacity. 285 | * 286 | * @param bool $transform 287 | * 288 | * @return array 289 | * @throws TransformerException 290 | */ 291 | public function getOptionsSnapshot(bool $transform = true): array 292 | { 293 | // just like singular options that we might select above, we might have 294 | // an in-memory cache of our complete option set. if so, we'll want to 295 | // use it to cut down on database queries. 296 | 297 | $snapshotName = $this->getOptionSnapshotName(); 298 | if ($this->isOptionCached($snapshotName)) { 299 | return $this->getCachedOption($snapshotName); 300 | } 301 | 302 | // if we didn't have a cached version of our options, we'll select them 303 | // from the database. then, we loop ovr them and transform each value 304 | // if necessary. because we might loop after our selection, we default 305 | // to an empty array if we've not previously saved a snapshot for these 306 | // options. 307 | 308 | $snapshot = $this->retrieveOption($snapshotName, []); 309 | if ($this->canTransformOptions($transform)) { 310 | 311 | // as elsewhere, even if we can transform values, we skip empties 312 | // so that we don't conflict with transformer method parameter type 313 | // hints. 314 | 315 | foreach ($snapshot as $option => &$value) { 316 | if (!empty($value)) { 317 | $value = $this->transformer->transformFromStorage($option, $value); 318 | } 319 | } 320 | } 321 | 322 | $this->maybeCacheOption($snapshotName, $snapshot); 323 | return $snapshot; 324 | } 325 | 326 | /** 327 | * getSnapshotName 328 | * 329 | * Returns a unique name for this handler's settings for use when saving or 330 | * retrieving them in a single database call. 331 | * 332 | * @return string 333 | */ 334 | protected function getOptionSnapshotName(): string 335 | { 336 | if ($this->optionSnapshotName !== null) { 337 | 338 | // if we've already done the work below, we don't need to do it 339 | // again. sure, we're only saving fractions of seconds but maybe 340 | // every little bit counts, and for a big array of options, the 341 | // join and hashing operation below could be expensive. 342 | 343 | return $this->optionSnapshotName; 344 | } 345 | 346 | // to try and make a automatic and repeatably generated option name, 347 | // we'll create the sha1 hash of our option names and add our prefix so 348 | // that a human will be able to see and recognize the hash as being 349 | // linked to the rest of this handler's data. a programmer can always 350 | // override this as necessary. 351 | 352 | $hashedNames = sha1(join('', $this->getOptionNames())); 353 | $snapshotName = $this->getOptionNamePrefix() . $hashedNames; 354 | 355 | // the codex tells us that an option name should be no longer than 64 356 | // characters. which is weird since the column is a VARCHAR(191) 357 | // field. but, we'll follow the rules and make sure that out option 358 | // name is no longer than the codex-specified limit. 359 | 360 | return ($this->optionSnapshotName = substr($snapshotName, 0, 64)); 361 | } 362 | 363 | /** 364 | * updateOption 365 | * 366 | * Ensures that we save this option's value using this plugin's option 367 | * prefix before calling the storeOption method and returning its results. 368 | * 369 | * @param string $option 370 | * @param mixed $value 371 | * @param bool $transform 372 | * 373 | * @return bool 374 | * @throws HandlerException 375 | * @throws TransformerException 376 | */ 377 | public function updateOption(string $option, $value, bool $transform = true): bool 378 | { 379 | // since we transform our $value before we cram it in the database, 380 | // it's easier for us to (maybe) add it to our cache first. that way, 381 | // we have the value the visitor sent us in memory and we don't have to 382 | // remember to transform it before using it elsewhere. 383 | 384 | $this->maybeCacheOption($option, $value); 385 | if ($this->isOptionValid($option)) { 386 | 387 | // if we can transform and our value isn't empty, we pass it 388 | // through the transformer. we skip empty values so that we don't 389 | // get tripped up by transformer method parameter type hints. 390 | 391 | $value = $this->canTransformOptions($transform) && !empty($value) 392 | ? $this->transformer->transformForStorage($option, $value) 393 | : $value; 394 | 395 | $fullOptionName = $this->getFullOptionName($option); 396 | return $this->storeOption($fullOptionName, $value); 397 | } 398 | 399 | return false; 400 | } 401 | 402 | /** 403 | * storeOption 404 | * 405 | * Stores a value in the database. Separated from other scopes so this 406 | * behavior can be overridden, e.g. for the storage of network options. 407 | * 408 | * @param string $option 409 | * @param mixed $value 410 | * 411 | * @return bool 412 | */ 413 | protected function storeOption(string $option, $value): bool 414 | { 415 | return update_option($option, $value); 416 | } 417 | 418 | /** 419 | * updateAllOptions 420 | * 421 | * Like the getAllOptions method above, this saves all of our information 422 | * in one call based on the mapping of option names to values represented 423 | * by the first parameter. 424 | * 425 | * @param array $values 426 | * @param bool $transform 427 | * 428 | * @return bool 429 | * @throws HandlerException 430 | * @throws TransformerException 431 | */ 432 | public function updateAllOptions(array $values, bool $transform = true): bool 433 | { 434 | $success = true; 435 | foreach ($values as $option => $value) { 436 | 437 | // the updateOption method returns true when it updates our option. 438 | // we Boolean AND that value with the current value of $success 439 | // which starts as true. so, as long as updateOption return true, 440 | // $success will remain set. but, the first time we hit a problem, 441 | // it'll be reset and will remain so because false AND anything is 442 | // false. 443 | 444 | $success = $success && $this->updateOption($option, $value, $transform); 445 | } 446 | 447 | return $success; 448 | } 449 | 450 | /** 451 | * updateOptionsSnapshot 452 | * 453 | * To reduce the number of database calls, this method saves all of this 454 | * handlers options in a single database entry. 455 | * 456 | * @param array $values 457 | * @param bool $transform 458 | * 459 | * @return bool 460 | * @throws HandlerException 461 | * @throws TransformerException 462 | */ 463 | public function updateOptionsSnapshot(array $values, bool $transform = true): bool 464 | { 465 | // since we're about to transform our values for storage, it's easier 466 | // for us to maybe store them in the cache first, then transform, then 467 | // update the database. then, we also update the record of all of our 468 | // options in the cache as well. finally, we update this information 469 | // in the individual options as well so that the snapshot records 470 | // matches. 471 | 472 | $snapshotName = $this->getOptionSnapshotName(); 473 | $this->maybeCacheOption($snapshotName, $values); 474 | $this->updateAllOptions($values, $transform); 475 | 476 | if ($this->canTransformOptions($transform)) { 477 | 478 | // if we want to transform and have a transformer, we'll go for it. 479 | // note that $value is a reference, so the changes we make within 480 | // the loop will remain when it completes. like elsewhere, we skip 481 | // empty values so they don't conflict with transformer method 482 | // parameter type hints. 483 | 484 | foreach ($values as $option => &$value) { 485 | if (!empty($value)) { 486 | $value = $this->transformer->transformForStorage($option, $value); 487 | } 488 | } 489 | } 490 | 491 | return $this->storeOption($snapshotName, $values); 492 | } 493 | 494 | /** 495 | * optionValueMatches 496 | * 497 | * Returns true if the $option's value in the database matches $value. 498 | * This is useful when determining whether or not an update to this option 499 | * is necessary. 500 | * 501 | * @param string $option 502 | * @param mixed $value 503 | * @param bool $transform 504 | * 505 | * @return bool 506 | * @throws HandlerException 507 | * @throws TransformerException 508 | */ 509 | public function optionValueMatches(string $option, $value, bool $transform = true): bool 510 | { 511 | // we don't want our handler to transform the value of $field as it 512 | // comes out of the database. doing so would likely mean that it would 513 | // become different from $value causing the system to try and update 514 | // things even if it doesn't have to. hence, we pass a false-flag to 515 | // the getOption method which prevents it from performing its 516 | // transformations. 517 | 518 | return $this->getOption($option, '', $transform) === $value; 519 | } 520 | 521 | /** 522 | * deleteOptions 523 | * 524 | * If our option parameter specifies a valid option for this object, then 525 | * we delete it. 526 | * 527 | * @param string $option 528 | * 529 | * @return bool|null 530 | * @throws HandlerException 531 | */ 532 | public function deleteOption(string $option): ?bool 533 | { 534 | // as in getOption above, it's hard to rely on other object methods 535 | // within Traits even if we're pretty sure they're going to have them. 536 | // so, instead of accessing the isDebug method of our handlers/agents, 537 | // we'll simply do it's expected work here re: determining the value 538 | // of the throw argument for isOptionValid 539 | 540 | if ($this->isOptionValid($option, defined('WP_DEBUG') && WP_DEBUG)) { 541 | $this->maybeDeleteCachedOption($option); 542 | $fullOptionName = $this->getFullOptionName($option); 543 | return $this->removeOption($fullOptionName); 544 | } 545 | 546 | // if our option wasn't valid, then we definitely didn't remove 547 | // anything from the database, but we want to separate this from a 548 | // failure to delete a valid one. so, we return null which would 549 | // evaluate to false if used in a conditional statement anyway. 550 | 551 | return null; 552 | } 553 | 554 | /** 555 | * maybeDeleteCachedOption 556 | * 557 | * If we're using the object option value cache, unset the $option index 558 | * of it to delete it from that cache. 559 | * 560 | * @param string $option 561 | */ 562 | protected function maybeDeleteCachedOption(string $option): void 563 | { 564 | if ($this->isOptionCached($option)) { 565 | unset($this->optionsCache[$option]); 566 | } 567 | } 568 | 569 | /** 570 | * removeOption 571 | * 572 | * Deletes an option from the database. It's separated from its 573 | * surrounding context so that we can alter this method, e.g. for deleting 574 | * network options. 575 | * 576 | * @param string $option 577 | * 578 | * @return bool 579 | */ 580 | protected function removeOption(string $option): bool 581 | { 582 | return delete_option($option); 583 | } 584 | } 585 | -------------------------------------------------------------------------------- /src/Traits/PostTypeRegistrationTrait.php: -------------------------------------------------------------------------------- 1 | _x($plural, $singular . ' General Name', $textDomain), 35 | 'singular_name' => _x($singular, $singular . ' Singular Name', $textDomain), 36 | 'menu_name' => __($plural, $textDomain), 37 | 'name_admin_bar' => __($singular, $textDomain), 38 | 'archives' => __($singular . ' Archives', $textDomain), 39 | 'attributes' => __($singular . ' Attributes', $textDomain), 40 | 'parent_item_colon' => __('Parent ' . $singular . ':', $textDomain), 41 | 'all_items' => __('All ' . $plural, $textDomain), 42 | 'add_new_item' => __('Add New ' . $singular, $textDomain), 43 | 'add_new' => __('Add New', $textDomain), 44 | 'new_item' => __('New ' . $singular, $textDomain), 45 | 'edit_item' => __('Edit ' . $singular, $textDomain), 46 | 'update_item' => __('Update ' . $singular, $textDomain), 47 | 'view_item' => __('View ' . $singular, $textDomain), 48 | 'view_items' => __('View ' . $plural, $textDomain), 49 | 'search_items' => __('Search ' . $singular, $textDomain), 50 | 'not_found' => __('Not found', $textDomain), 51 | 'not_found_in_trash' => __('Not found in Trash', $textDomain), 52 | 'featured_image' => __(ucwords($thumbnailLabel), $textDomain), 53 | 'set_featured_image' => __('Set ' . $thumbnailLabel, $textDomain), 54 | 'remove_featured_image' => __('Remove ' . $thumbnailLabel, $textDomain), 55 | 'use_featured_image' => __('Use as ' . $thumbnailLabel, $textDomain), 56 | 'insert_into_item' => __('Add to ' . $singular, $textDomain), 57 | 'uploaded_to_this_item' => __('Uploaded to this ' . $singular, $textDomain), 58 | 'items_list' => __($plural . ' list', $textDomain), 59 | 'items_list_navigation' => __($plural . ' list navigation', $textDomain), 60 | 'filter_items_list' => __('Filter ' . $plural . ' list', $textDomain), 61 | 'item_published' => __($singular . ' published.', $textDomain), 62 | 'item_published_privately' => __($singular . ' published privately.', $textDomain), 63 | 'item_reverted_to_draft' => __($singular . ' reverted to draft.'), 64 | 'item_scheduled' => __($singular . ' scheduled.', $textDomain), 65 | 'item_updated' => __($singular . ' updated.', $textDomain), 66 | 'item_link' => __($singular . ' Link', $textDomain), 67 | 'item_link_description' => __('A link to a ' . $singular . '.', $textDomain), 68 | ]; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Traits/TaxonomyRegistrationTrait.php: -------------------------------------------------------------------------------- 1 | _x($plural, $singular . ' General Name', $textDomain), 24 | 'singular_name' => _x($singular, $singular . ' Singular Name', $textDomain), 25 | 'menu_name' => __($plural, $textDomain), 26 | 'all_items' => __('All ' . $plural, $textDomain), 27 | 'parent_item' => __('Parent ' . $singular, $textDomain), 28 | 'parent_item_colon' => __('Parent ' . $singular . ':', $textDomain), 29 | 'new_item_name' => __('New ' . $singular . ' Name', $textDomain), 30 | 'add_new_item' => __('Add New ' . $singular, $textDomain), 31 | 'edit_item' => __('Edit ' . $singular, $textDomain), 32 | 'update_item' => __('Update ' . $singular, $textDomain), 33 | 'view_item' => __('View ' . $singular, $textDomain), 34 | 'separate_items_with_commas' => __('Separate ' . $plural . ' with commas', $textDomain), 35 | 'add_or_remove_items' => __('Add or remove ' . $plural, $textDomain), 36 | 'choose_from_most_used' => __('Choose from the most used ' . $plural, $textDomain), 37 | 'popular_items' => __('Popular ' . $plural, $textDomain), 38 | 'search_items' => __('Search ' . $plural, $textDomain), 39 | 'not_found' => __('Not Found', $textDomain), 40 | 'no_terms' => __('No ' . $plural, $textDomain), 41 | 'items_list' => __($plural . ' list', $textDomain), 42 | 'items_list_navigation' => __($plural . ' list navigation', $textDomain), 43 | 'filter_by_item' => __('Filter by ' . $singular, $textDomain), 44 | 'back_to_items' => __('Back to ' . $plural, $textDomain), 45 | 'item_link' => __($singular . ' Link', $textDomain), 46 | 'item_link_description' => __('A link to a ' . $singular, $textDomain), 47 | ]; 48 | } 49 | } 50 | --------------------------------------------------------------------------------